mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-24 00:09:32 +00:00
Compare commits
44 Commits
claude/sla
...
fix-vuenod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15582f055f | ||
|
|
f6d39dbfc8 | ||
|
|
97ca9f489e | ||
|
|
7b274b74f1 | ||
|
|
44c317fd05 | ||
|
|
23e9b39593 | ||
|
|
dcfa53fd7d | ||
|
|
2d5d18c020 | ||
|
|
6883241e50 | ||
|
|
e3906a0656 | ||
|
|
3ce588ad42 | ||
|
|
818c5c32e5 | ||
|
|
dbb0bd961f | ||
|
|
11bd9022c8 | ||
|
|
df1eb32907 | ||
|
|
52b94e06a1 | ||
|
|
7bc6334065 | ||
|
|
8086f977c9 | ||
|
|
f843d779c2 | ||
|
|
bce4f876f4 | ||
|
|
4b095f3701 | ||
|
|
c8e181c841 | ||
|
|
41ffb7c627 | ||
|
|
5029a0b32c | ||
|
|
f240ecaaff | ||
|
|
6a1da7a7af | ||
|
|
a6ca2bcd42 | ||
|
|
886fe07de9 | ||
|
|
43c162a862 | ||
|
|
92f21c14d4 | ||
|
|
1bf5b5397d | ||
|
|
51a7654a39 | ||
|
|
644a8bc60c | ||
|
|
9e434a1002 | ||
|
|
a2e0c3d596 | ||
|
|
e26e1f0c9e | ||
|
|
af094ebefc | ||
|
|
eea24166e0 | ||
|
|
3bd74dcf39 | ||
|
|
405e756d4c | ||
|
|
0ca27f3d9b | ||
|
|
b54ed97557 | ||
|
|
15a05afc27 | ||
|
|
1bde87838d |
5
.github/workflows/ci-tests-e2e-forks.yaml
vendored
5
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -1,9 +1,9 @@
|
||||
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
|
||||
name: "CI: Tests E2E (Deploy for Forks)"
|
||||
name: 'CI: Tests E2E (Deploy for Forks)'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI: Tests E2E"]
|
||||
workflows: ['CI: Tests E2E']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
|
||||
7
.github/workflows/ci-tests-e2e.yaml
vendored
7
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -1,5 +1,5 @@
|
||||
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
|
||||
name: "CI: Tests E2E"
|
||||
name: 'CI: Tests E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -222,6 +222,7 @@ jobs:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -6,10 +6,11 @@ const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4.1',
|
||||
splitToken: 1024,
|
||||
saveImmediately: true,
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
@@ -18,5 +19,11 @@ module.exports = defineConfig({
|
||||
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
|
||||
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
|
||||
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
|
||||
|
||||
IMPORTANT Persian Translation Guidelines:
|
||||
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
`
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: ['@storybook/addon-docs'],
|
||||
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {}
|
||||
@@ -69,9 +69,32 @@ const config: StorybookConfig = {
|
||||
allowedHosts: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': process.cwd() + '/src'
|
||||
}
|
||||
alias: [
|
||||
{
|
||||
find: '@/composables/queue/useJobList',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/formatUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/networkUtil',
|
||||
replacement:
|
||||
process.cwd() +
|
||||
'/packages/shared-frontend-utils/src/networkUtil.ts'
|
||||
},
|
||||
{
|
||||
find: '@',
|
||||
replacement: process.cwd() + '/src'
|
||||
}
|
||||
]
|
||||
},
|
||||
esbuild: {
|
||||
// Prevent minification of identifiers to preserve _sfc_main
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Imports:
|
||||
- sorted/grouped by plugin
|
||||
- run `pnpm format` before committing
|
||||
- use separate `import type` statements, not inline `type` in mixed imports
|
||||
- ✅ `import type { Foo } from './foo'` + `import { bar } from './foo'`
|
||||
- ❌ `import { bar, type Foo } from './foo'`
|
||||
- ESLint:
|
||||
- Vue + TS rules
|
||||
- no floating promises
|
||||
@@ -119,7 +122,10 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Prefer reactive props destructuring to `const props = defineProps<...>`
|
||||
- Do not use `withDefaults` or runtime props declaration
|
||||
- Do not import Vue macros unnecessarily
|
||||
- Prefer `useModel` to separately defining a prop and emit
|
||||
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
|
||||
- Define slots via template usage, not `defineSlots`
|
||||
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
|
||||
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
|
||||
- Be judicious with addition of new refs or other state
|
||||
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
||||
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
||||
@@ -137,7 +143,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
8. Implement proper error handling
|
||||
9. Follow Vue 3 style guide and naming conventions
|
||||
10. Use Vite for fast development and building
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
|
||||
12. Avoid new usage of PrimeVue components
|
||||
13. Write tests for all changes, especially bug fixes to catch future regressions
|
||||
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
|
||||
@@ -155,6 +161,8 @@ The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
See @docs/testing/*.md for detailed patterns.
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
@@ -268,6 +276,8 @@ When referencing Comfy-Org repos:
|
||||
- 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.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
138
docs/testing/vitest-patterns.md
Normal file
138
docs/testing/vitest-patterns.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
globs:
|
||||
- '**/*.test.ts'
|
||||
- '**/*.spec.ts'
|
||||
---
|
||||
|
||||
# Vitest Patterns
|
||||
|
||||
## Setup
|
||||
|
||||
Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:
|
||||
|
||||
```typescript
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('MyStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||
|
||||
## Mock Patterns
|
||||
|
||||
### Reset all mocks at once
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks() // Not individual mock.mockReset() calls
|
||||
})
|
||||
```
|
||||
|
||||
### Module mocks with vi.mock()
|
||||
|
||||
```typescript
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
fetchData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/myService', () => ({
|
||||
myService: {
|
||||
doThing: vi.fn()
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### Configure mocks in tests
|
||||
|
||||
```typescript
|
||||
import { api } from '@/scripts/api'
|
||||
import { myService } from '@/services/myService'
|
||||
|
||||
it('handles success', () => {
|
||||
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
|
||||
// ... test code
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Event Listeners
|
||||
|
||||
When a store registers event listeners at module load time:
|
||||
|
||||
```typescript
|
||||
function getEventHandler() {
|
||||
const call = vi.mocked(api.addEventListener).mock.calls.find(
|
||||
([event]) => event === 'my_event'
|
||||
)
|
||||
return call?.[1] as (e: CustomEvent<MyEventType>) => void
|
||||
}
|
||||
|
||||
function dispatch(data: MyEventType) {
|
||||
const handler = getEventHandler()
|
||||
handler(new CustomEvent('my_event', { detail: data }))
|
||||
}
|
||||
|
||||
it('handles events', () => {
|
||||
const store = useMyStore()
|
||||
dispatch({ field: 'value' })
|
||||
expect(store.items).toHaveLength(1)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with Fake Timers
|
||||
|
||||
For stores with intervals, timeouts, or polling:
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('polls after delay', async () => {
|
||||
const store = useMyStore()
|
||||
store.startPolling()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
|
||||
expect(mockService.fetch).toHaveBeenCalled()
|
||||
})
|
||||
```
|
||||
|
||||
## Assertion Style
|
||||
|
||||
Prefer `.toHaveLength()` over `.length.toBe()`:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
expect(store.items).toHaveLength(1)
|
||||
|
||||
// Avoid
|
||||
expect(store.items.length).toBe(1)
|
||||
```
|
||||
|
||||
Use `.toMatchObject()` for partial matching:
|
||||
|
||||
```typescript
|
||||
expect(store.completedItems[0]).toMatchObject({
|
||||
id: 'task-123',
|
||||
status: 'done'
|
||||
})
|
||||
```
|
||||
@@ -8,7 +8,8 @@ const config: KnipConfig = {
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts'
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.37.5",
|
||||
"version": "1.37.9",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -66,6 +66,7 @@
|
||||
"@prettier/plugin-oxc": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@storybook/addon-docs": "catalog:",
|
||||
"@storybook/addon-mcp": "catalog:",
|
||||
"@storybook/vue3": "catalog:",
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
|
||||
134
pnpm-lock.yaml
generated
134
pnpm-lock.yaml
generated
@@ -84,6 +84,9 @@ catalogs:
|
||||
'@storybook/addon-docs':
|
||||
specifier: ^10.1.9
|
||||
version: 10.1.9
|
||||
'@storybook/addon-mcp':
|
||||
specifier: 0.1.6
|
||||
version: 0.1.6
|
||||
'@storybook/vue3':
|
||||
specifier: ^10.1.9
|
||||
version: 10.1.9
|
||||
@@ -549,6 +552,9 @@ importers:
|
||||
'@storybook/addon-docs':
|
||||
specifier: 'catalog:'
|
||||
version: 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))
|
||||
'@storybook/addon-mcp':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.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)
|
||||
'@storybook/vue3':
|
||||
specifier: 'catalog:'
|
||||
version: 10.1.9(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))(vue@3.5.13(typescript@5.9.3))
|
||||
@@ -3148,6 +3154,11 @@ packages:
|
||||
peerDependencies:
|
||||
storybook: ^10.1.9
|
||||
|
||||
'@storybook/addon-mcp@0.1.6':
|
||||
resolution: {integrity: sha512-+EagCHqwIb9tg3DKskEsXpsqQVnMljxgR5Tt3Bu0ZpWweB1HdMy+ok128gzNfTZ3r+5ljksr0q66YCEkrQwdDA==}
|
||||
peerDependencies:
|
||||
storybook: ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0
|
||||
|
||||
'@storybook/builder-vite@10.1.9':
|
||||
resolution: {integrity: sha512-rUILpjGV7gKfXrUeZzpNAer9PspB3LJI1d+gJHISx2Gs24bdneA3y/gu0fWw46ccOSIcwb91xoK5QxliJcWsWg==}
|
||||
peerDependencies:
|
||||
@@ -3181,6 +3192,9 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@storybook/mcp@0.1.1':
|
||||
resolution: {integrity: sha512-+AivFDms1XkY2VUvZBBYy0co5qvRh20eYXYwhaDPQXX2Q4y96arSkWn22e/l3DQwA9Ywzv481vj4gl4zPrCQkg==}
|
||||
|
||||
'@storybook/react-dom-shim@10.1.9':
|
||||
resolution: {integrity: sha512-gJsR6fI1gG4DSin6sQx8RmGDQF8Lije0cZbxHyVedNleBsveGXIPFUKFVi+pRNdwBPni1Z2g/gYyHzkOEqPD2w==}
|
||||
peerDependencies:
|
||||
@@ -3453,6 +3467,26 @@ packages:
|
||||
'@tiptap/starter-kit@2.10.4':
|
||||
resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==}
|
||||
|
||||
'@tmcp/adapter-valibot@0.1.5':
|
||||
resolution: {integrity: sha512-9P2wrVYPngemNK0UvPb/opC722/jfd09QxXmme1TRp/wPsl98vpSk/MXt24BCMqBRv4Dvs0xxJH4KHDcjXW52Q==}
|
||||
peerDependencies:
|
||||
tmcp: ^1.17.0
|
||||
valibot: ^1.1.0
|
||||
|
||||
'@tmcp/session-manager@0.2.1':
|
||||
resolution: {integrity: sha512-DOGy9LfufXCy1wfpGHZ6qPSDQtRnTVwOb71+41ffovTqzLMZlK3iLK/LIsekHxIiku+iIAUiqEKN+DHbqEm8IA==}
|
||||
peerDependencies:
|
||||
tmcp: ^1.16.3
|
||||
|
||||
'@tmcp/transport-http@0.8.3':
|
||||
resolution: {integrity: sha512-gnoBjDBd8/ppl4WRrNKPKHlioCxE8D0zTyNUOzqUjsg0s6GRsyB5iMirh9lC4QjQt0NEOrI+sIJdz+9ymf0MDA==}
|
||||
peerDependencies:
|
||||
'@tmcp/auth': ^0.3.3 || ^0.4.0
|
||||
tmcp: ^1.18.0
|
||||
peerDependenciesMeta:
|
||||
'@tmcp/auth':
|
||||
optional: true
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@5.2.2':
|
||||
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
|
||||
engines: {node: '>18.12'}
|
||||
@@ -3786,6 +3820,11 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@valibot/to-json-schema@1.5.0':
|
||||
resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==}
|
||||
peerDependencies:
|
||||
valibot: ^1.2.0
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3':
|
||||
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -5170,6 +5209,9 @@ packages:
|
||||
jiti:
|
||||
optional: true
|
||||
|
||||
esm-env@1.2.2:
|
||||
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
||||
|
||||
esm-resolve@1.0.11:
|
||||
resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==}
|
||||
|
||||
@@ -5978,6 +6020,9 @@ packages:
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-rpc-2.0@1.7.1:
|
||||
resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -7386,6 +7431,9 @@ packages:
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
sqids@0.3.0:
|
||||
resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==}
|
||||
|
||||
stable-hash-x@0.2.0:
|
||||
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -7613,6 +7661,9 @@ packages:
|
||||
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
|
||||
hasBin: true
|
||||
|
||||
tmcp@1.19.0:
|
||||
resolution: {integrity: sha512-wOY449EdaWDo7wLZEOVjeH9fn/AqfFF4f+3pDerCI8xHpy2Z8msUjAF0Vkg01aEFIdFMmiNDiY4hu6E7jVX79w==}
|
||||
|
||||
tmp@0.2.5:
|
||||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||
engines: {node: '>=14.14'}
|
||||
@@ -7858,6 +7909,9 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
uri-template-matcher@1.1.2:
|
||||
resolution: {integrity: sha512-uZc1h12jdO3m/R77SfTEOuo6VbMhgWznaawKpBjRGSJb7i91x5PgI37NQJtG+Cerxkk0yr1pylBY2qG1kQ+aEQ==}
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
@@ -7873,6 +7927,14 @@ packages:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.2.0:
|
||||
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -8019,6 +8081,9 @@ packages:
|
||||
vue-component-type-helpers@3.2.1:
|
||||
resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.2:
|
||||
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -10949,6 +11014,18 @@ snapshots:
|
||||
- vite
|
||||
- webpack
|
||||
|
||||
'@storybook/addon-mcp@0.1.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)':
|
||||
dependencies:
|
||||
'@storybook/mcp': 0.1.1(typescript@5.9.3)
|
||||
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
|
||||
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
|
||||
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)
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@tmcp/auth'
|
||||
- typescript
|
||||
|
||||
'@storybook/builder-vite@10.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:
|
||||
'@storybook/csf-plugin': 10.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))
|
||||
@@ -10978,6 +11055,16 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
'@storybook/mcp@0.1.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
|
||||
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@tmcp/auth'
|
||||
- typescript
|
||||
|
||||
'@storybook/react-dom-shim@10.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(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))':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
@@ -11007,7 +11094,7 @@ snapshots:
|
||||
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)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.1
|
||||
vue-component-type-helpers: 3.2.2
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -11275,6 +11362,23 @@ snapshots:
|
||||
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
|
||||
'@tiptap/pm': 2.10.4
|
||||
|
||||
'@tmcp/adapter-valibot@0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@tmcp/session-manager@0.2.1(tmcp@1.19.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
|
||||
'@tmcp/transport-http@0.8.3(tmcp@1.19.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tmcp/session-manager': 0.2.1(tmcp@1.19.0(typescript@5.9.3))
|
||||
esm-env: 1.2.2
|
||||
tmcp: 1.19.0(typescript@5.9.3)
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
|
||||
dependencies:
|
||||
'@babel/generator': 7.28.5
|
||||
@@ -11623,6 +11727,10 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))':
|
||||
dependencies:
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.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))(vue@3.5.13(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
@@ -13303,6 +13411,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
esm-env@1.2.2: {}
|
||||
|
||||
esm-resolve@1.0.11: {}
|
||||
|
||||
espree@10.4.0:
|
||||
@@ -14189,6 +14299,8 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-rpc-2.0@1.7.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -16055,6 +16167,8 @@ snapshots:
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
sqids@0.3.0: {}
|
||||
|
||||
stable-hash-x@0.2.0: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
@@ -16347,6 +16461,16 @@ snapshots:
|
||||
dependencies:
|
||||
tldts-core: 7.0.19
|
||||
|
||||
tmcp@1.19.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
json-rpc-2.0: 1.7.1
|
||||
sqids: 0.3.0
|
||||
uri-template-matcher: 1.1.2
|
||||
valibot: 1.2.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
tmp@0.2.5: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
@@ -16644,6 +16768,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
uri-template-matcher@1.1.2: {}
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
@@ -16654,6 +16780,10 @@ snapshots:
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
valibot@1.2.0(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -16914,6 +17044,8 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.1: {}
|
||||
|
||||
vue-component-type-helpers@3.2.2: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
|
||||
@@ -29,6 +29,7 @@ catalog:
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
|
||||
@@ -10,37 +10,158 @@ interface TestStats {
|
||||
finished?: number
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
status: string
|
||||
duration?: number
|
||||
error?: {
|
||||
message?: string
|
||||
stack?: string
|
||||
}
|
||||
attachments?: Array<{
|
||||
name: string
|
||||
path?: string
|
||||
contentType: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
title: string
|
||||
ok: boolean
|
||||
outcome: string
|
||||
results: TestResult[]
|
||||
}
|
||||
|
||||
interface Suite {
|
||||
title: string
|
||||
file: string
|
||||
suites?: Suite[]
|
||||
tests?: TestCase[]
|
||||
}
|
||||
|
||||
interface FullReportData {
|
||||
stats?: TestStats
|
||||
suites?: Suite[]
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
stats?: TestStats
|
||||
}
|
||||
|
||||
interface FailedTest {
|
||||
name: string
|
||||
file: string
|
||||
traceUrl?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TestCounts {
|
||||
passed: number
|
||||
failed: number
|
||||
flaky: number
|
||||
skipped: number
|
||||
total: number
|
||||
failures?: FailedTest[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract failed test details from Playwright report
|
||||
*/
|
||||
function extractFailedTests(
|
||||
reportData: FullReportData,
|
||||
baseUrl?: string
|
||||
): FailedTest[] {
|
||||
const failures: FailedTest[] = []
|
||||
|
||||
function processTest(test: TestCase, file: string, suitePath: string[]) {
|
||||
// Check if test failed or is flaky
|
||||
const hasFailed = test.results.some(
|
||||
(r) => r.status === 'failed' || r.status === 'timedOut'
|
||||
)
|
||||
const isFlaky = test.outcome === 'flaky'
|
||||
|
||||
if (hasFailed || isFlaky) {
|
||||
const fullTestName = [...suitePath, test.title]
|
||||
.filter(Boolean)
|
||||
.join(' › ')
|
||||
const failedResult = test.results.find(
|
||||
(r) => r.status === 'failed' || r.status === 'timedOut'
|
||||
)
|
||||
|
||||
// Find trace attachment
|
||||
let traceUrl: string | undefined
|
||||
if (failedResult?.attachments) {
|
||||
const traceAttachment = failedResult.attachments.find(
|
||||
(a) => a.name === 'trace' && a.contentType === 'application/zip'
|
||||
)
|
||||
if (traceAttachment?.path) {
|
||||
// Convert local path to URL path
|
||||
const tracePath = traceAttachment.path.replace(/\\/g, '/')
|
||||
const traceFile = path.basename(tracePath)
|
||||
if (baseUrl) {
|
||||
// Construct trace viewer URL
|
||||
const traceDataUrl = `${baseUrl}/data/${traceFile}`
|
||||
traceUrl = `${baseUrl}/trace/?trace=${encodeURIComponent(traceDataUrl)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failures.push({
|
||||
name: fullTestName,
|
||||
file: file,
|
||||
traceUrl,
|
||||
error: failedResult?.error?.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function processSuite(suite: Suite, parentPath: string[] = []) {
|
||||
const suitePath = suite.title ? [...parentPath, suite.title] : parentPath
|
||||
|
||||
// Process tests in this suite
|
||||
if (suite.tests) {
|
||||
for (const test of suite.tests) {
|
||||
processTest(test, suite.file, suitePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process nested suites
|
||||
if (suite.suites) {
|
||||
for (const childSuite of suite.suites) {
|
||||
processSuite(childSuite, suitePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reportData.suites) {
|
||||
for (const suite of reportData.suites) {
|
||||
processSuite(suite)
|
||||
}
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract test counts from Playwright HTML report
|
||||
* @param reportDir - Path to the playwright-report directory
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total }
|
||||
* @param baseUrl - Base URL of the deployed report (for trace links)
|
||||
* @returns Test counts { passed, failed, flaky, skipped, total, failures }
|
||||
*/
|
||||
function extractTestCounts(reportDir: string): TestCounts {
|
||||
function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
|
||||
const counts: TestCounts = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
flaky: 0,
|
||||
skipped: 0,
|
||||
total: 0
|
||||
total: 0,
|
||||
failures: []
|
||||
}
|
||||
|
||||
try {
|
||||
// First, try to find report.json which Playwright generates with JSON reporter
|
||||
const jsonReportFile = path.join(reportDir, 'report.json')
|
||||
if (fs.existsSync(jsonReportFile)) {
|
||||
const reportJson: ReportData = JSON.parse(
|
||||
const reportJson: FullReportData = JSON.parse(
|
||||
fs.readFileSync(jsonReportFile, 'utf-8')
|
||||
)
|
||||
if (reportJson.stats) {
|
||||
@@ -54,6 +175,12 @@ function extractTestCounts(reportDir: string): TestCounts {
|
||||
counts.failed = stats.unexpected || 0
|
||||
counts.flaky = stats.flaky || 0
|
||||
counts.skipped = stats.skipped || 0
|
||||
|
||||
// Extract detailed failure information
|
||||
if (counts.failed > 0 || counts.flaky > 0) {
|
||||
counts.failures = extractFailedTests(reportJson, baseUrl)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
}
|
||||
@@ -169,15 +296,18 @@ function extractTestCounts(reportDir: string): TestCounts {
|
||||
|
||||
// Main execution
|
||||
const reportDir = process.argv[2]
|
||||
const baseUrl = process.argv[3] // Optional: base URL for trace links
|
||||
|
||||
if (!reportDir) {
|
||||
console.error('Usage: extract-playwright-counts.ts <report-directory>')
|
||||
console.error(
|
||||
'Usage: extract-playwright-counts.ts <report-directory> [base-url]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const counts = extractTestCounts(reportDir)
|
||||
const counts = extractTestCounts(reportDir, baseUrl)
|
||||
|
||||
// Output as JSON for easy parsing in shell script
|
||||
console.log(JSON.stringify(counts))
|
||||
process.stdout.write(JSON.stringify(counts) + '\n')
|
||||
|
||||
export { extractTestCounts }
|
||||
export { extractTestCounts, extractFailedTests }
|
||||
|
||||
@@ -134,23 +134,22 @@ post_comment() {
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post starting comment
|
||||
# Post concise starting comment
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
## 🎭 Playwright Tests: ⏳ Running...
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
|
||||
Tests started at $START_TIME UTC
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
<details>
|
||||
<summary>📊 Browser Tests</summary>
|
||||
|
||||
### 🚀 Running Tests
|
||||
- 🧪 **chromium**: Running tests...
|
||||
- 🧪 **chromium-0.5x**: Running tests...
|
||||
- 🧪 **chromium-2x**: Running tests...
|
||||
- 🧪 **mobile-chrome**: Running tests...
|
||||
- **chromium**: Running...
|
||||
- **chromium-0.5x**: Running...
|
||||
- **chromium-2x**: Running...
|
||||
- **mobile-chrome**: Running...
|
||||
|
||||
---
|
||||
⏱️ Please wait while tests are running...
|
||||
</details>
|
||||
EOF
|
||||
)
|
||||
post_comment "$comment"
|
||||
@@ -189,7 +188,8 @@ else
|
||||
|
||||
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
|
||||
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
|
||||
# Pass the base URL so we can generate trace links
|
||||
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" "$url" 2>&1 || echo '{}')
|
||||
echo "Extracted counts for $browser: $counts" >&2
|
||||
echo "$counts" > "$temp_dir/$i.counts"
|
||||
else
|
||||
@@ -286,43 +286,74 @@ else
|
||||
# Determine overall status
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
status_icon="❌"
|
||||
status_text="Some tests failed"
|
||||
status_text="Failed"
|
||||
elif [ $total_flaky -gt 0 ]; then
|
||||
status_icon="⚠️"
|
||||
status_text="Tests passed with flaky tests"
|
||||
status_text="Passed with flaky tests"
|
||||
elif [ $total_tests -gt 0 ]; then
|
||||
status_icon="✅"
|
||||
status_text="All tests passed!"
|
||||
status_text="Passed"
|
||||
else
|
||||
status_icon="🕵🏻"
|
||||
status_text="No test results found"
|
||||
status_text="No test results"
|
||||
fi
|
||||
|
||||
# Generate completion comment
|
||||
# Generate concise completion comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright Test Results
|
||||
|
||||
$status_icon **$status_text**
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
|
||||
## 🎭 Playwright Tests: $status_icon **$status_text**"
|
||||
|
||||
# Add summary counts if we have test data
|
||||
if [ $total_tests -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### 📈 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 '')"
|
||||
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
|
||||
fi
|
||||
|
||||
# Extract and display failed tests from all browsers
|
||||
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### ❌ Failed Tests"
|
||||
|
||||
# Process each browser's failures
|
||||
for counts_json in "${counts_array[@]}"; do
|
||||
[ -z "$counts_json" ] || [ "$counts_json" = "{}" ] && continue
|
||||
|
||||
if command -v jq > /dev/null 2>&1; then
|
||||
# Extract failures array from JSON
|
||||
failures=$(echo "$counts_json" | jq -r '.failures // [] | .[]? | "\(.name)|\(.file)|\(.traceUrl // "")"')
|
||||
|
||||
if [ -n "$failures" ]; then
|
||||
while IFS='|' read -r test_name test_file trace_url; do
|
||||
[ -z "$test_name" ] && continue
|
||||
|
||||
# Convert file path to GitHub URL (relative to repo root)
|
||||
github_file_url="https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/$test_file"
|
||||
|
||||
# Build the failed test line
|
||||
test_line="- [$test_name]($github_file_url)"
|
||||
|
||||
if [ -n "$trace_url" ] && [ "$trace_url" != "null" ]; then
|
||||
test_line="$test_line: [View trace]($trace_url)"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
$test_line"
|
||||
done <<< "$failures"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Add browser reports in collapsible section
|
||||
comment="$comment
|
||||
|
||||
### 📊 Test Reports by Browser"
|
||||
<details>
|
||||
<summary>📊 Browser Reports</summary>
|
||||
|
||||
"
|
||||
|
||||
# Add browser results with individual counts
|
||||
# Add browser results
|
||||
i=0
|
||||
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
|
||||
IFS=' ' read -r -a url_array <<< "$urls"
|
||||
@@ -349,7 +380,7 @@ $status_icon **$status_text**
|
||||
fi
|
||||
|
||||
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
|
||||
counts_str=" • ✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
|
||||
counts_str=" (✅ $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped)"
|
||||
else
|
||||
counts_str=""
|
||||
fi
|
||||
@@ -358,10 +389,10 @@ $status_icon **$status_text**
|
||||
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
|
||||
@@ -369,8 +400,7 @@ $status_icon **$status_text**
|
||||
|
||||
comment="$comment
|
||||
|
||||
---
|
||||
🎉 Click on the links above to view detailed test results for each browser configuration."
|
||||
</details>"
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -49,13 +54,16 @@
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</Button>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
@@ -91,15 +99,19 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
@@ -111,8 +123,15 @@ const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
@@ -120,6 +139,12 @@ const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
|
||||
backgroundClass || 'bg-secondary-background'
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
|
||||
backgroundClass
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
30
src/components/common/StatusBadge.vue
Normal file
30
src/components/common/StatusBadge.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
|
||||
|
||||
const { label, severity = 'default' } = defineProps<{
|
||||
label: string
|
||||
severity?: Severity
|
||||
}>()
|
||||
|
||||
function badgeClasses(sev: Severity): string {
|
||||
const baseClasses =
|
||||
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
|
||||
|
||||
switch (sev) {
|
||||
case 'danger':
|
||||
return `${baseClasses} bg-destructive-background text-white`
|
||||
case 'contrast':
|
||||
return `${baseClasses} bg-base-foreground text-base-background`
|
||||
case 'warn':
|
||||
return `${baseClasses} bg-warning-background text-base-background`
|
||||
case 'secondary':
|
||||
return `${baseClasses} bg-secondary-background text-base-foreground`
|
||||
default:
|
||||
return `${baseClasses} bg-primary-background text-base-foreground`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClasses(severity)">{{ label }}</span>
|
||||
</template>
|
||||
@@ -563,7 +563,8 @@ const {
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
@@ -815,10 +816,10 @@ const pageTitle = computed(() => {
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run all operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
workflowTemplatesStore.loadWorkflowTemplates(),
|
||||
loadFuseOptions()
|
||||
])
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
|
||||
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{
|
||||
isCloud
|
||||
|
||||
@@ -1,180 +1,260 @@
|
||||
<template>
|
||||
<div class="flex w-112 flex-col gap-8 p-8">
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-semibold text-base-foreground m-0">
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h1>
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0 w-96">
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0">
|
||||
{{ $t('credits.topUp.creditsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Current Balance Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<UserCredit text-class="text-3xl font-bold" show-credits-only />
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
$t('credits.creditsAvailable')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Options Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.howManyCredits') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
<CreditTopUpOption
|
||||
v-for="option in creditOptions"
|
||||
:key="option.credits"
|
||||
:credits="option.credits"
|
||||
:description="option.description"
|
||||
:selected="selectedCredits === option.credits"
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
{{ t('subscription.videoTemplateBasedCredits') }}
|
||||
</span>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- Buy Button -->
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:disabled="!isValidAmount || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
{{ $t('credits.topUp.buyCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from 'primevue'
|
||||
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 { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
|
||||
interface CreditOption {
|
||||
credits: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
}>()
|
||||
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const selectedCredits = ref<number | null>(null)
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const popover = ref()
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 30 })
|
||||
},
|
||||
{
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 60 })
|
||||
},
|
||||
{
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 120 })
|
||||
},
|
||||
{
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 301 })
|
||||
}
|
||||
]
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!selectedCredits.value) return
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
// Prevent double-clicks
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const usdAmount = creditsToUsd(selectedCredits.value)
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
|
||||
await authActions.purchaseCredits(usdAmount)
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
await authActions.purchaseCredits(payAmount.value)
|
||||
|
||||
// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('subscription')
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
@@ -220,6 +220,12 @@ function show(event: MouseEvent) {
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
// Initialize last* values to current transform to prevent updateMenuPosition
|
||||
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
134
src/components/helpcenter/HelpCenterPopups.vue
Normal file
134
src/components/helpcenter/HelpCenterPopups.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
<HelpCenterMenuContent @close="closeHelpCenter" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
@whats-new-dismissed="handleWhatsNewDismissed"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const { isSmall = false } = defineProps<{
|
||||
isSmall?: boolean
|
||||
}>()
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
} = useHelpCenter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
293
src/components/honeyToast/HoneyToast.stories.ts
Normal file
293
src/components/honeyToast/HoneyToast.stories.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import HoneyToast from './HoneyToast.vue'
|
||||
|
||||
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesTotal: 1000000,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0,
|
||||
status: 'created',
|
||||
lastUpdate: Date.now(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<typeof HoneyToast> = {
|
||||
title: 'Toast/HoneyToast',
|
||||
component: HoneyToast,
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(false)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'running',
|
||||
progress: 0.45
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-3',
|
||||
assetName: 'vae-decoder.safetensors',
|
||||
status: 'created'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Expanded: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(true)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'running',
|
||||
progress: 0.45
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-3',
|
||||
assetName: 'vae-decoder.safetensors',
|
||||
status: 'created'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(false)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesDownloaded: 1000000,
|
||||
progress: 1,
|
||||
status: 'completed'
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetId: 'asset-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
bytesTotal: 500000,
|
||||
bytesDownloaded: 500000,
|
||||
progress: 1,
|
||||
status: 'completed'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
|
||||
<span class="font-bold text-base-foreground">All downloads completed</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
<Button variant="muted-textonly" size="icon">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(true)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'failed',
|
||||
progress: 0.23
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
|
||||
<span class="font-bold text-base-foreground">1 download failed</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
<Button variant="muted-textonly" size="icon">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Hidden: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast },
|
||||
template: `
|
||||
<div>
|
||||
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
|
||||
|
||||
<HoneyToast :visible="false">
|
||||
<template #default>
|
||||
<div class="px-4 py-4">Content</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="h-12 px-4">Footer</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
137
src/components/honeyToast/HoneyToast.test.ts
Normal file
137
src/components/honeyToast/HoneyToast.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import HoneyToast from './HoneyToast.vue'
|
||||
|
||||
describe('HoneyToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountComponent(
|
||||
props: { visible: boolean; expanded?: boolean } = { visible: true }
|
||||
): VueWrapper {
|
||||
return mount(HoneyToast, {
|
||||
props,
|
||||
slots: {
|
||||
default: (slotProps: { isExpanded: boolean }) =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-testid': 'content' },
|
||||
slotProps.isExpanded ? 'expanded' : 'collapsed'
|
||||
),
|
||||
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'toggle-btn',
|
||||
onClick: slotProps.toggle
|
||||
},
|
||||
slotProps.isExpanded ? 'Collapse' : 'Expand'
|
||||
)
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
}
|
||||
|
||||
it('renders when visible is true', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeTruthy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render when visible is false', async () => {
|
||||
const wrapper = mountComponent({ visible: false })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeFalsy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes is-expanded=false to slots by default', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies collapsed max-height class when collapsed', async () => {
|
||||
const wrapper = mountComponent({ visible: true, expanded: false })
|
||||
await nextTick()
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for accessibility', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast?.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('supports v-model:expanded with reactive parent state', async () => {
|
||||
const TestWrapper = defineComponent({
|
||||
components: { HoneyToast },
|
||||
setup() {
|
||||
const expanded = ref(false)
|
||||
return { expanded }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast :visible="true" v-model:expanded="expanded">
|
||||
<template #default="slotProps">
|
||||
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
|
||||
</template>
|
||||
<template #footer="slotProps">
|
||||
<button data-testid="toggle-btn" @click="slotProps.toggle">
|
||||
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper, { attachTo: document.body })
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
|
||||
const toggleBtn = document.body.querySelector(
|
||||
'[data-testid="toggle-btn"]'
|
||||
) as HTMLButtonElement
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
|
||||
|
||||
toggleBtn?.click()
|
||||
await nextTick()
|
||||
|
||||
expect(content?.textContent).toBe('expanded')
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
46
src/components/honeyToast/HoneyToast.vue
Normal file
46
src/components/honeyToast/HoneyToast.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { visible } = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const isExpanded = defineModel<boolean>('expanded', { default: false })
|
||||
|
||||
function toggle() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="translate-y-full opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot :is-expanded />
|
||||
</div>
|
||||
|
||||
<slot name="footer" :is-expanded :toggle />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -160,7 +160,7 @@
|
||||
>
|
||||
<i
|
||||
v-if="slotProps.selected"
|
||||
class="text-bold icon-[lucide--check] text-xs text-white"
|
||||
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
|
||||
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-backdrop/30"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@@ -14,12 +14,12 @@
|
||||
class="rounded-full"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<i class="pi pi-bars text-lg text-white" />
|
||||
<i class="pi pi-bars text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-show="isMenuOpen"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
@@ -29,13 +29,13 @@
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center justify-start',
|
||||
activeCategory === category && 'bg-smoke-600'
|
||||
activeCategory === category && 'bg-button-active-surface'
|
||||
)
|
||||
"
|
||||
@click="selectCategory(category)"
|
||||
>
|
||||
<i :class="getCategoryIcon(category)" />
|
||||
<span class="whitespace-nowrap text-white">{{
|
||||
<span class="whitespace-nowrap text-base-foreground">{{
|
||||
$t(categoryLabels[category])
|
||||
}}</span>
|
||||
</Button>
|
||||
@@ -169,7 +169,7 @@ const getCategoryIcon = (category: string) => {
|
||||
export: 'pi pi-download'
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return `${icons[category]} text-white text-lg`
|
||||
return `${icons[category]} text-base-foreground text-lg`
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="spinner" />
|
||||
<div class="mt-4 text-lg text-white">
|
||||
<div class="mt-4 text-lg text-base-foreground">
|
||||
{{ loadingMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:class="[
|
||||
'pi',
|
||||
playing ? 'pi-pause' : 'pi-play',
|
||||
'text-lg text-white'
|
||||
'text-lg text-base-foreground'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
@@ -46,7 +46,7 @@
|
||||
class="flex-1"
|
||||
@update:model-value="handleSliderChange"
|
||||
/>
|
||||
<span class="min-w-16 text-xs text-white">
|
||||
<span class="min-w-16 text-xs text-base-foreground">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:aria-label="$t('load3d.switchCamera')"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i :class="['pi', 'pi-camera', 'text-lg text-white']" />
|
||||
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
:aria-label="$t('load3d.exportModel')"
|
||||
@click="toggleExportFormats"
|
||||
>
|
||||
<i class="pi pi-download text-lg text-white" />
|
||||
<i class="pi pi-download text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showExportFormats"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
v-for="format in exportFormats"
|
||||
:key="format.value"
|
||||
variant="textonly"
|
||||
class="text-white"
|
||||
class="text-base-foreground"
|
||||
@click="exportModel(format.value)"
|
||||
>
|
||||
{{ format.label }}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:aria-label="$t('load3d.lightIntensity')"
|
||||
@click="toggleLightIntensity"
|
||||
>
|
||||
<i class="pi pi-sun text-lg text-white" />
|
||||
<i class="pi pi-sun text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showLightIntensity"
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
:aria-label="t('load3d.upDirection')"
|
||||
@click="toggleUpDirection"
|
||||
>
|
||||
<i class="pi pi-arrow-up text-lg text-white" />
|
||||
<i class="pi pi-arrow-up text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showUpDirection"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
@@ -24,7 +24,10 @@
|
||||
:key="direction"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn('text-white', upDirection === direction && 'bg-blue-500')
|
||||
cn(
|
||||
'text-base-foreground',
|
||||
upDirection === direction && 'bg-blue-500'
|
||||
)
|
||||
"
|
||||
@click="selectUpDirection(direction)"
|
||||
>
|
||||
@@ -46,11 +49,11 @@
|
||||
:aria-label="t('load3d.materialMode')"
|
||||
@click="toggleMaterialMode"
|
||||
>
|
||||
<i class="pi pi-box text-lg text-white" />
|
||||
<i class="pi pi-box text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showMaterialMode"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
@@ -59,7 +62,7 @@
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'whitespace-nowrap text-white',
|
||||
'whitespace-nowrap text-base-foreground',
|
||||
materialMode === mode && 'bg-blue-500'
|
||||
)
|
||||
"
|
||||
@@ -83,7 +86,7 @@
|
||||
:aria-label="t('load3d.showSkeleton')"
|
||||
@click="showSkeleton = !showSkeleton"
|
||||
>
|
||||
<i class="pi pi-sitemap text-lg text-white" />
|
||||
<i class="pi pi-sitemap text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
:aria-label="tooltipText"
|
||||
@click="toggleSlider"
|
||||
>
|
||||
<i :class="['pi', icon, 'text-lg text-white']" />
|
||||
<i :class="['pi', icon, 'text-lg text-base-foreground']" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showSlider"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg w-[150px]"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface p-4 shadow-lg w-[150px]"
|
||||
>
|
||||
<Slider
|
||||
v-model="value"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-smoke-700/30">
|
||||
<div class="relative rounded-lg bg-backdrop/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
@@ -25,7 +25,7 @@
|
||||
:class="[
|
||||
'pi',
|
||||
isRecording ? 'pi-circle-fill' : 'pi-video',
|
||||
'text-lg text-white'
|
||||
'text-lg text-base-foreground'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
@@ -42,7 +42,7 @@
|
||||
:aria-label="$t('load3d.exportRecording')"
|
||||
@click="handleExportRecording"
|
||||
>
|
||||
<i class="pi pi-download text-lg text-white" />
|
||||
<i class="pi pi-download text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -57,12 +57,12 @@
|
||||
:aria-label="$t('load3d.clearRecording')"
|
||||
@click="handleClearRecording"
|
||||
>
|
||||
<i class="pi pi-trash text-lg text-white" />
|
||||
<i class="pi pi-trash text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
|
||||
class="mt-1 text-center text-xs text-white"
|
||||
class="mt-1 text-center text-xs text-base-foreground"
|
||||
>
|
||||
{{ formatDuration(recordingDuration) }}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:aria-label="$t('load3d.showGrid')"
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<i class="pi pi-table text-lg text-white" />
|
||||
<i class="pi pi-table text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
@@ -23,7 +23,7 @@
|
||||
:aria-label="$t('load3d.backgroundColor')"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
<i class="pi pi-palette text-lg text-white" />
|
||||
<i class="pi pi-palette text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="colorPickerRef"
|
||||
type="color"
|
||||
@@ -48,7 +48,7 @@
|
||||
:aria-label="$t('load3d.uploadBackgroundImage')"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<i class="pi pi-image text-lg text-white" />
|
||||
<i class="pi pi-image text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="imagePickerRef"
|
||||
type="file"
|
||||
@@ -76,7 +76,7 @@
|
||||
:aria-label="$t('load3d.panoramaMode')"
|
||||
@click="toggleBackgroundRenderMode"
|
||||
>
|
||||
<i class="pi pi-globe text-lg text-white" />
|
||||
<i class="pi pi-globe text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
:aria-label="$t('load3d.removeBackgroundImage')"
|
||||
@click="removeBackgroundImage"
|
||||
>
|
||||
<i class="pi pi-times text-lg text-white" />
|
||||
<i class="pi pi-times text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-smoke-700/30">
|
||||
<div class="relative rounded-lg bg-backdrop/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
@@ -12,7 +12,7 @@
|
||||
:aria-label="t('load3d.openIn3DViewer')"
|
||||
@click="openIn3DViewer"
|
||||
>
|
||||
<i class="pi pi-expand text-lg text-white" />
|
||||
<i class="pi pi-expand text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
@keydown.stop
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-current"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
@@ -35,6 +35,74 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="h-5 border-l border-border" />
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.rotateLeft')"
|
||||
@click="onRotateLeft"
|
||||
>
|
||||
<svg
|
||||
viewBox="-6 -7 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.rotateRight')"
|
||||
@click="onRotateRight"
|
||||
>
|
||||
<svg
|
||||
viewBox="-9 -7 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<g transform="scale(-1, 1)">
|
||||
<path
|
||||
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.mirrorHorizontal')"
|
||||
@click="onMirrorHorizontal"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
|
||||
/>
|
||||
<path d="M3.5,4.5l-2,3,2,3v-6ZM11.5,4.5v6l2-3-2-3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.mirrorVertical')"
|
||||
@click="onMirrorVertical"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
|
||||
/>
|
||||
<path d="M4.5,4.5l3-2,3,2h-6ZM4.5,10.5h6l-3,2-3-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px bg-[var(--p-form-field-border-color)]" />
|
||||
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
@@ -63,6 +131,7 @@ import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -71,16 +140,17 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
const canvasTransform = useCanvasTransform()
|
||||
const saver = useMaskEditorSaver()
|
||||
|
||||
const saveButtonText = ref(t('g.save'))
|
||||
const saveEnabled = ref(true)
|
||||
|
||||
const iconButtonClass =
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-border-default pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
'h-7.5 w-15 rounded-[10px] border border-border-default text-current font-sans pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
|
||||
|
||||
const onUndo = () => {
|
||||
store.canvasHistory.undo()
|
||||
@@ -90,6 +160,38 @@ const onRedo = () => {
|
||||
store.canvasHistory.redo()
|
||||
}
|
||||
|
||||
const onRotateLeft = async () => {
|
||||
try {
|
||||
await canvasTransform.rotateCounterclockwise()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Rotate left failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onRotateRight = async () => {
|
||||
try {
|
||||
await canvasTransform.rotateClockwise()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Rotate right failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onMirrorHorizontal = async () => {
|
||||
try {
|
||||
await canvasTransform.mirrorHorizontal()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Mirror horizontal failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onMirrorVertical = async () => {
|
||||
try {
|
||||
await canvasTransform.mirrorVertical()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Mirror vertical failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onInvert = () => {
|
||||
canvasTools.invertMask()
|
||||
}
|
||||
|
||||
@@ -50,20 +50,22 @@
|
||||
<div
|
||||
v-if="
|
||||
props.state === 'running' &&
|
||||
(props.progressTotalPercent !== undefined ||
|
||||
props.progressCurrentPercent !== undefined)
|
||||
hasAnyProgressPercent(
|
||||
props.progressTotalPercent,
|
||||
props.progressCurrentPercent
|
||||
)
|
||||
"
|
||||
class="absolute inset-0"
|
||||
:class="progressBarContainerClass"
|
||||
>
|
||||
<div
|
||||
v-if="props.progressTotalPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${props.progressTotalPercent}%` }"
|
||||
v-if="hasProgressPercent(props.progressTotalPercent)"
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(props.progressTotalPercent)"
|
||||
/>
|
||||
<div
|
||||
v-if="props.progressCurrentPercent !== undefined"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${props.progressCurrentPercent}%` }"
|
||||
v-if="hasProgressPercent(props.progressCurrentPercent)"
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(props.progressCurrentPercent)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +203,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
@@ -245,6 +248,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
progressBarContainerClass,
|
||||
progressBarPrimaryClass,
|
||||
progressBarSecondaryClass,
|
||||
hasProgressPercent,
|
||||
hasAnyProgressPercent,
|
||||
progressPercentStyle
|
||||
} = useProgressBarBackground()
|
||||
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="sideToolbarRef"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
|
||||
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
|
||||
:class="{
|
||||
'small-sidebar': isSmall,
|
||||
'connected-sidebar': isConnected,
|
||||
'connected-sidebar pointer-events-auto': isConnected,
|
||||
'floating-sidebar': !isConnected,
|
||||
'overflowing-sidebar': isOverflowing,
|
||||
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
|
||||
@@ -40,12 +40,13 @@
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +55,7 @@ import { useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
@@ -89,6 +91,9 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
selectedTab.value ||
|
||||
@@ -145,8 +150,8 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
const isOverflowing = ref(false)
|
||||
const groupClasses = computed(() =>
|
||||
cn(
|
||||
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
|
||||
(isConnected.value ? '' : ' rounded-lg shadow-interface')
|
||||
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0',
|
||||
!isConnected.value && 'rounded-lg shadow-interface pointer-events-auto'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,204 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
<HelpCenterMenuContent @close="closeHelpCenter" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
@whats-new-dismissed="handleWhatsNewDismissed"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, toRefs } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
|
||||
// Use conflict acknowledgment state from composable - call only once
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
|
||||
useConflictAcknowledgment()
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
const { isSmall } = toRefs(props)
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle Help Center and track UI button click.
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
helpCenterStore.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle What's New popup dismissal
|
||||
* Check if conflict modal should be shown after ComfyUI update
|
||||
*/
|
||||
const handleWhatsNewDismissed = async () => {
|
||||
try {
|
||||
// Check if conflict modal should be shown after update
|
||||
const shouldShow =
|
||||
await conflictDetection.shouldShowConflictModalAfterUpdate()
|
||||
if (shouldShow) {
|
||||
showConflictModal()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Error checking conflict modal:', error)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Show the node conflict dialog with current conflict data
|
||||
*/
|
||||
const showConflictModal = () => {
|
||||
showNodeConflictDialog({
|
||||
showAfterWhatsNew: true,
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
onMounted(async () => {
|
||||
// Initialize release store to fetch releases for toast and popup
|
||||
await releaseStore.initialize()
|
||||
})
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-badge) {
|
||||
background: #ff3b30;
|
||||
color: #ff3b30;
|
||||
|
||||
154
src/components/sidebar/tabs/AssetsSidebarListView.stories.ts
Normal file
154
src/components/sidebar/tabs/AssetsSidebarListView.stories.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { JobAction } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
|
||||
import { setMockJobItems } from '@/storybook/mocks/useJobList'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
type StoryArgs = {
|
||||
assets: AssetItem[]
|
||||
jobs: JobListItem[]
|
||||
selectedAssetIds?: string[]
|
||||
actionsByJobId?: Record<string, JobAction[]>
|
||||
}
|
||||
|
||||
function baseDecorator() {
|
||||
return {
|
||||
template: `
|
||||
<div class="bg-base-background p-6">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/Sidebar/AssetsSidebarListView',
|
||||
component: AssetsSidebarListView,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
decorators: [baseDecorator]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const baseTimestamp = '2024-01-15T10:00:00Z'
|
||||
|
||||
const sampleJobs: JobListItem[] = [
|
||||
{
|
||||
id: 'job-pending-1',
|
||||
title: 'In queue',
|
||||
meta: '8:59:30pm',
|
||||
state: 'pending',
|
||||
iconName: iconForJobState('pending'),
|
||||
showClear: true
|
||||
},
|
||||
{
|
||||
id: 'job-init-1',
|
||||
title: 'Initializing...',
|
||||
meta: '8:59:35pm',
|
||||
state: 'initialization',
|
||||
iconName: iconForJobState('initialization'),
|
||||
showClear: true
|
||||
},
|
||||
{
|
||||
id: 'job-running-1',
|
||||
title: 'Total: 30%',
|
||||
meta: 'KSampler: 70%',
|
||||
state: 'running',
|
||||
iconName: iconForJobState('running'),
|
||||
showClear: true,
|
||||
progressTotalPercent: 30,
|
||||
progressCurrentPercent: 70
|
||||
}
|
||||
]
|
||||
|
||||
const sampleAssets: AssetItem[] = [
|
||||
{
|
||||
id: 'asset-image-1',
|
||||
name: 'image-032.png',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/comfy-logo-single.svg',
|
||||
size: 1887437,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
promptId: 'job-running-1',
|
||||
nodeId: 12,
|
||||
executionTimeInSeconds: 1.84
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-video-1',
|
||||
name: 'clip-01.mp4',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 8394820,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
duration: 132000
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-audio-1',
|
||||
name: 'soundtrack-01.mp3',
|
||||
created_at: baseTimestamp,
|
||||
size: 5242880,
|
||||
tags: [],
|
||||
user_metadata: {
|
||||
duration: 200000
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'asset-3d-1',
|
||||
name: 'scene-01.glb',
|
||||
created_at: baseTimestamp,
|
||||
size: 134217728,
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
|
||||
const cancelAction: JobAction = {
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: 'Cancel',
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
export const RunningAndGenerated: Story = {
|
||||
args: {
|
||||
assets: sampleAssets,
|
||||
jobs: sampleJobs,
|
||||
actionsByJobId: {
|
||||
'job-pending-1': [cancelAction],
|
||||
'job-init-1': [cancelAction],
|
||||
'job-running-1': [cancelAction]
|
||||
}
|
||||
},
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
return {
|
||||
components: { AssetsSidebarListView },
|
||||
setup() {
|
||||
setMockJobItems(args.jobs)
|
||||
setMockJobActions(args.actionsByJobId ?? {})
|
||||
const selectedIds = new Set(args.selectedAssetIds ?? [])
|
||||
function isSelected(assetId: string) {
|
||||
return selectedIds.has(assetId)
|
||||
}
|
||||
|
||||
return { args, isSelected }
|
||||
},
|
||||
template: `
|
||||
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
|
||||
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
203
src/components/sidebar/tabs/AssetsSidebarListView.vue
Normal file
203
src/components/sidebar/tabs/AssetsSidebarListView.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
v-for="job in activeJobItems"
|
||||
:key="job.id"
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
'cursor-default'
|
||||
)
|
||||
"
|
||||
:preview-url="job.iconImageUrl"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@mouseenter="onJobEnter(job.id)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="canCancelJob"
|
||||
:variant="cancelAction.variant"
|
||||
size="icon"
|
||||
:aria-label="cancelAction.label"
|
||||
@click.stop="runCancelJob()"
|
||||
>
|
||||
<i :class="cancelAction.icon" class="size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
{{ t('sideToolbar.generatedAssetsHeader') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VirtualGrid
|
||||
class="flex-1"
|
||||
:items="assetItems"
|
||||
:grid-style="listGridStyle"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<AssetsListItem
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
type: getMediaTypeFromFilename(item.asset.name)
|
||||
})
|
||||
"
|
||||
:class="getAssetCardClass(isSelected(item.asset.id))"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="
|
||||
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
|
||||
"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getMediaTypeFromFilename,
|
||||
truncateFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { assets, isSelected } = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
|
||||
type AssetListItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state))
|
||||
)
|
||||
const hoveredJob = computed(() =>
|
||||
hoveredJobId.value
|
||||
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
|
||||
null)
|
||||
: null
|
||||
)
|
||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
||||
|
||||
const assetItems = computed<AssetListItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
}))
|
||||
)
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)',
|
||||
padding: '0 0.5rem',
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function isActiveJobState(state: JobState): boolean {
|
||||
return (
|
||||
state === 'pending' || state === 'initialization' || state === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetSecondaryText(asset: AssetItem): string {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
|
||||
}
|
||||
|
||||
const duration = asset.user_metadata?.duration
|
||||
if (typeof duration === 'number') {
|
||||
return formatDuration(duration)
|
||||
}
|
||||
|
||||
if (typeof asset.size === 'number') {
|
||||
return formatSize(asset.size)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getAssetCardClass(selected: boolean): string {
|
||||
return cn(
|
||||
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
'cursor-pointer',
|
||||
selected &&
|
||||
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
|
||||
)
|
||||
}
|
||||
|
||||
function onJobEnter(jobId: string) {
|
||||
hoveredJobId.value = jobId
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
const classes = []
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||
classes.push('animate-spin')
|
||||
}
|
||||
return classes.length ? classes.join(' ') : undefined
|
||||
}
|
||||
</script>
|
||||
@@ -79,10 +79,10 @@
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
<div v-if="showLoadingState">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<div v-else-if="!loading && !displayAssets.length">
|
||||
<div v-else-if="showEmptyState">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
@@ -96,7 +96,15 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
@select-asset="handleAssetSelect"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<VirtualGrid
|
||||
v-else
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
@@ -114,11 +122,15 @@
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:open-context-menu-id="openContextMenuId"
|
||||
:selected-assets="getSelectedAssets(displayAssets)"
|
||||
:has-selection="hasSelection"
|
||||
@click="handleAssetSelect(item)"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
@context-menu-opened="openContextMenuId = item.id"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -134,7 +146,6 @@
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
>
|
||||
@@ -198,6 +209,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
@@ -232,6 +244,9 @@ const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const isListView = computed(
|
||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
@@ -243,11 +258,6 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const getOutputCount = (item: AssetItem): number => {
|
||||
const count = item.user_metadata?.outputCount
|
||||
return typeof count === 'number' && count > 0 ? count : 1
|
||||
}
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -285,6 +295,8 @@ const {
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
getOutputCount,
|
||||
getTotalOutputCount,
|
||||
activate: activateSelection,
|
||||
deactivate: deactivateSelection
|
||||
} = useAssetSelection()
|
||||
@@ -316,7 +328,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
|
||||
return getTotalOutputCount(selectedAssets)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
@@ -347,6 +359,20 @@ const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
)
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
(!isListView.value || activeJobsCount.value === 0)
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
@@ -537,6 +563,16 @@ const handleDeleteSelected = async () => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
downloadMultipleAssets(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||
await deleteMultipleAssets(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
95
src/components/toast/ProgressToastItem.stories.ts
Normal file
95
src/components/toast/ProgressToastItem.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
import ProgressToastItem from './ProgressToastItem.vue'
|
||||
|
||||
const meta: Meta<typeof ProgressToastItem> = {
|
||||
title: 'Toast/ProgressToastItem',
|
||||
component: ProgressToastItem,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesTotal: 1000000,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0,
|
||||
status: 'created',
|
||||
lastUpdate: Date.now(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'created',
|
||||
assetName: 'sd-xl-base-1.0.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Running: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.45,
|
||||
assetName: 'lora-detail-enhancer.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningAlmostComplete: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.92,
|
||||
assetName: 'vae-ft-mse-840000.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'completed',
|
||||
progress: 1,
|
||||
assetName: 'controlnet-canny.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'failed',
|
||||
progress: 0.23,
|
||||
assetName: 'unreachable-model.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const LongFileName: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.67,
|
||||
assetName:
|
||||
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
65
src/components/toast/ProgressToastItem.vue
Normal file
65
src/components/toast/ProgressToastItem.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { job } = defineProps<{
|
||||
job: AssetDownload
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const progressPercent = computed(() => Math.round(job.progress * 100))
|
||||
const isCompleted = computed(() => job.status === 'completed')
|
||||
const isFailed = computed(() => job.status === 'failed')
|
||||
const isRunning = computed(() => job.status === 'running')
|
||||
const isPending = computed(() => job.status === 'created')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
|
||||
isCompleted && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm text-base-foreground">{{
|
||||
job.assetName
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<template v-if="isFailed">
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
|
||||
/>
|
||||
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isCompleted">
|
||||
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isRunning">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
|
||||
/>
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ progressPercent }}%
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isPending">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('progressToast.pending') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,11 +9,16 @@
|
||||
@click="popover?.toggle($event)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
|
||||
compact && 'size-full '
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserAvatar :photo-url="photoURL" />
|
||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
||||
|
||||
<i class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -38,9 +43,15 @@ import { computed, ref } from 'vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
showArrow?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="!isLoggedIn"
|
||||
variant="secondary"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full bg-secondary-background text-base-foreground hover:bg-secondary-background-hover"
|
||||
:class="cn('group rounded-full text-base-foreground p-0', className)"
|
||||
:aria-label="t('g.login')"
|
||||
@click="handleSignIn()"
|
||||
@mouseenter="showPopover"
|
||||
@mouseleave="hidePopover"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4" />
|
||||
<span
|
||||
class="flex size-full items-center justify-center rounded-full bg-secondary-background transition-colors group-hover:bg-transparent"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4" />
|
||||
</span>
|
||||
</Button>
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
@@ -31,12 +35,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { isLoggedIn, handleSignIn } = useCurrentUser()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
21
src/components/topbar/TopMenuHelpButton.vue
Normal file
21
src/components/topbar/TopMenuHelpButton.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<Button
|
||||
class="comfy-help-center-btn relative text-base-foreground"
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
{{ $t('menu.helpAndFeedback') }}
|
||||
<i class="icon-[lucide--circle-help] ml-0.5" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
|
||||
</script>
|
||||
@@ -161,7 +161,7 @@ describe('TopbarBadge', () => {
|
||||
)
|
||||
|
||||
expect(wrapper.find('.bg-gold-600').exists()).toBe(true)
|
||||
expect(wrapper.find('.text-gold-600').exists()).toBe(true)
|
||||
expect(wrapper.find('.text-warning-background').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses default error icon for error variant', () => {
|
||||
@@ -185,7 +185,9 @@ describe('TopbarBadge', () => {
|
||||
'full'
|
||||
)
|
||||
|
||||
expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true)
|
||||
expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ const textClasses = computed(() => {
|
||||
case 'error':
|
||||
return 'text-danger-100'
|
||||
case 'warning':
|
||||
return 'text-gold-600'
|
||||
return 'text-warning-background'
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-text-primary'
|
||||
@@ -191,7 +191,7 @@ const iconClass = computed(() => {
|
||||
case 'error':
|
||||
return 'pi pi-exclamation-circle'
|
||||
case 'warning':
|
||||
return 'pi pi-exclamation-triangle'
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
case 'info':
|
||||
default:
|
||||
return undefined
|
||||
|
||||
@@ -67,6 +67,19 @@
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
<div
|
||||
v-if="isIntegratedTabBar"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<TopMenuHelpButton />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn"
|
||||
:show-arrow="false"
|
||||
compact
|
||||
class="shrink-0 p-1"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems" />
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
</div>
|
||||
@@ -81,9 +94,14 @@ import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
@@ -107,10 +125,16 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
|
||||
const rightClickedTab = ref<WorkflowOption | undefined>()
|
||||
const menu = ref()
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
181
src/components/ui/stepper/FormattedNumberStepper.vue
Normal file
181
src/components/ui/stepper/FormattedNumberStepper.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<label
|
||||
:for="inputId"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 cursor-text items-center rounded-lg bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover focus-within:ring-1 focus-within:ring-secondary-foreground',
|
||||
disabled && 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-l-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||
:disabled="disabled || modelValue <= min"
|
||||
:aria-label="$t('g.decrement')"
|
||||
@click="handleStep(-1)"
|
||||
>
|
||||
<i class="icon-[lucide--minus] size-4" />
|
||||
</button>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center gap-0.5 overflow-hidden"
|
||||
>
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:style="{ width: `${inputWidth}ch` }"
|
||||
class="min-w-0 rounded border-none bg-transparent text-center text-base-foreground font-medium text-lg focus-visible:outline-none"
|
||||
:disabled="disabled"
|
||||
@input="handleInputChange"
|
||||
@blur="handleInputBlur"
|
||||
@focus="handleInputFocus"
|
||||
/>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-r-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||
:disabled="disabled || modelValue >= max"
|
||||
:aria-label="$t('g.increment')"
|
||||
@click="handleStep(1)"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min = 0,
|
||||
max = Infinity,
|
||||
step = 1,
|
||||
formatOptions = { useGrouping: true },
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number | ((value: number) => number)
|
||||
formatOptions?: Intl.NumberFormatOptions
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'max-reached': []
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const inputId = useId()
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const inputValue = ref(formatNumber(modelValue.value))
|
||||
|
||||
const inputWidth = computed(() =>
|
||||
Math.min(Math.max(inputValue.value.length, 1) + 0.5, 9)
|
||||
)
|
||||
|
||||
watch(modelValue, (newValue) => {
|
||||
if (document.activeElement !== inputRef.value) {
|
||||
inputValue.value = formatNumber(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US', formatOptions)
|
||||
}
|
||||
|
||||
function parseFormattedNumber(str: string): number {
|
||||
const cleaned = str.replace(/[^0-9]/g, '')
|
||||
return cleaned === '' ? 0 : parseInt(cleaned, 10)
|
||||
}
|
||||
|
||||
function clamp(value: number, minVal: number, maxVal: number): number {
|
||||
return Math.min(Math.max(value, minVal), maxVal)
|
||||
}
|
||||
|
||||
function formatWithCursor(
|
||||
value: string,
|
||||
cursorPos: number
|
||||
): { formatted: string; newCursor: number } {
|
||||
const num = parseFormattedNumber(value)
|
||||
const formatted = formatNumber(num)
|
||||
|
||||
const digitsBeforeCursor = value
|
||||
.slice(0, cursorPos)
|
||||
.replace(/[^0-9]/g, '').length
|
||||
|
||||
let digitCount = 0
|
||||
let newCursor = 0
|
||||
for (let i = 0; i < formatted.length; i++) {
|
||||
if (/[0-9]/.test(formatted[i])) {
|
||||
digitCount++
|
||||
}
|
||||
if (digitCount >= digitsBeforeCursor) {
|
||||
newCursor = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (digitCount < digitsBeforeCursor) {
|
||||
newCursor = formatted.length
|
||||
}
|
||||
|
||||
return { formatted, newCursor }
|
||||
}
|
||||
|
||||
function getStepAmount(): number {
|
||||
return typeof step === 'function' ? step(modelValue.value) : step
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const raw = input.value
|
||||
const cursorPos = input.selectionStart ?? raw.length
|
||||
const num = parseFormattedNumber(raw)
|
||||
|
||||
const clamped = Math.min(num, max)
|
||||
const wasClamped = num > max
|
||||
|
||||
if (wasClamped) {
|
||||
emit('max-reached')
|
||||
}
|
||||
|
||||
modelValue.value = clamped
|
||||
|
||||
const { formatted, newCursor } = formatWithCursor(
|
||||
wasClamped ? formatNumber(clamped) : raw,
|
||||
wasClamped ? formatNumber(clamped).length : cursorPos
|
||||
)
|
||||
inputValue.value = formatted
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.value?.setSelectionRange(newCursor, newCursor)
|
||||
})
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
const clamped = clamp(modelValue.value, min, max)
|
||||
modelValue.value = clamped
|
||||
inputValue.value = formatNumber(clamped)
|
||||
}
|
||||
|
||||
function handleInputFocus(e: FocusEvent) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const len = input.value.length
|
||||
input.setSelectionRange(len, len)
|
||||
}
|
||||
|
||||
function handleStep(direction: 1 | -1) {
|
||||
const stepAmount = getStepAmount()
|
||||
const newValue = clamp(modelValue.value + stepAmount * direction, min, max)
|
||||
modelValue.value = newValue
|
||||
inputValue.value = formatNumber(newValue)
|
||||
}
|
||||
</script>
|
||||
@@ -13,10 +13,11 @@
|
||||
<i class="icon-[lucide--panel-right] text-sm" />
|
||||
</Button>
|
||||
<Button
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
|
||||
size="lg"
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times text-sm"></i>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
<div class="flex h-full w-full">
|
||||
<Transition name="slide-panel">
|
||||
@@ -80,7 +81,9 @@
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto">
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<div v-if="icon" class="py-0.5">
|
||||
<div v-if="icon" class="pt-0.5">
|
||||
<NavIcon :icon="icon" />
|
||||
</div>
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface VueNodeData {
|
||||
hasErrors?: boolean
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
resizable?: boolean
|
||||
shape?: number
|
||||
subgraphId?: string | null
|
||||
titleMode?: TitleMode
|
||||
@@ -244,17 +245,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
const updatedWidgets = currentData.widgets?.map((widget) => {
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
|
||||
})
|
||||
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets,
|
||||
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
|
||||
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
|
||||
})
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
@@ -325,6 +319,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="@webgpu/types" />
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
@@ -233,6 +234,128 @@ export function useBrushDrawing(initialSettings?: {
|
||||
}
|
||||
)
|
||||
|
||||
const isRecreatingTextures = ref(false)
|
||||
|
||||
watch(
|
||||
() => store.gpuTexturesNeedRecreation,
|
||||
async (needsRecreation) => {
|
||||
if (
|
||||
!needsRecreation ||
|
||||
!device ||
|
||||
!store.maskCanvas ||
|
||||
isRecreatingTextures.value
|
||||
)
|
||||
return
|
||||
|
||||
isRecreatingTextures.value = true
|
||||
|
||||
const width = store.gpuTextureWidth
|
||||
const height = store.gpuTextureHeight
|
||||
|
||||
try {
|
||||
// Destroy old textures
|
||||
if (maskTexture) {
|
||||
maskTexture.destroy()
|
||||
maskTexture = null
|
||||
}
|
||||
if (rgbTexture) {
|
||||
rgbTexture.destroy()
|
||||
rgbTexture = null
|
||||
}
|
||||
|
||||
// Create new textures with updated dimensions
|
||||
maskTexture = device.createTexture({
|
||||
size: [width, height],
|
||||
format: 'rgba8unorm',
|
||||
usage:
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.STORAGE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_DST |
|
||||
GPUTextureUsage.COPY_SRC
|
||||
})
|
||||
|
||||
rgbTexture = device.createTexture({
|
||||
size: [width, height],
|
||||
format: 'rgba8unorm',
|
||||
usage:
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.STORAGE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_DST |
|
||||
GPUTextureUsage.COPY_SRC
|
||||
})
|
||||
|
||||
// Upload pending data if available
|
||||
if (store.pendingGPUMaskData && store.pendingGPURgbData) {
|
||||
device.queue.writeTexture(
|
||||
{ texture: maskTexture },
|
||||
store.pendingGPUMaskData,
|
||||
{ bytesPerRow: width * 4 },
|
||||
{ width, height }
|
||||
)
|
||||
|
||||
device.queue.writeTexture(
|
||||
{ texture: rgbTexture },
|
||||
store.pendingGPURgbData,
|
||||
{ bytesPerRow: width * 4 },
|
||||
{ width, height }
|
||||
)
|
||||
} else {
|
||||
// Fallback: read from canvas
|
||||
await updateGPUFromCanvas()
|
||||
}
|
||||
|
||||
// Update preview canvas if it exists
|
||||
if (previewCanvas && renderer) {
|
||||
previewCanvas.width = width
|
||||
previewCanvas.height = height
|
||||
}
|
||||
|
||||
// Recreate readback buffers with new size
|
||||
const bufferSize = width * height * 4
|
||||
if (currentBufferSize !== bufferSize) {
|
||||
readbackStorageMask?.destroy()
|
||||
readbackStorageRgb?.destroy()
|
||||
readbackStagingMask?.destroy()
|
||||
readbackStagingRgb?.destroy()
|
||||
|
||||
readbackStorageMask = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
|
||||
})
|
||||
readbackStorageRgb = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
|
||||
})
|
||||
readbackStagingMask = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
|
||||
})
|
||||
readbackStagingRgb = device.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
|
||||
})
|
||||
|
||||
currentBufferSize = bufferSize
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useBrushDrawing] Failed to recreate GPU textures:',
|
||||
error
|
||||
)
|
||||
} finally {
|
||||
// Clear the recreation flag and pending data
|
||||
store.gpuTexturesNeedRecreation = false
|
||||
store.gpuTextureWidth = 0
|
||||
store.gpuTextureHeight = 0
|
||||
store.pendingGPUMaskData = null
|
||||
store.pendingGPURgbData = null
|
||||
isRecreatingTextures.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Cleanup GPU resources on unmount
|
||||
onUnmounted(() => {
|
||||
if (renderer) {
|
||||
|
||||
@@ -2,23 +2,70 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
|
||||
|
||||
let mockMaskCanvas: any
|
||||
let mockRgbCanvas: any
|
||||
let mockMaskCtx: any
|
||||
let mockRgbCtx: any
|
||||
// Define the store shape to avoid 'any' and cast to the expected type
|
||||
interface MaskEditorStoreState {
|
||||
maskCanvas: HTMLCanvasElement | null
|
||||
rgbCanvas: HTMLCanvasElement | null
|
||||
imgCanvas: HTMLCanvasElement | null
|
||||
maskCtx: CanvasRenderingContext2D | null
|
||||
rgbCtx: CanvasRenderingContext2D | null
|
||||
imgCtx: CanvasRenderingContext2D | null
|
||||
}
|
||||
|
||||
const mockStore = {
|
||||
maskCanvas: null as any,
|
||||
rgbCanvas: null as any,
|
||||
maskCtx: null as any,
|
||||
rgbCtx: null as any
|
||||
// Use vi.hoisted to create isolated mock state container
|
||||
const mockRefs = vi.hoisted(() => ({
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
imgCanvas: null as HTMLCanvasElement | null,
|
||||
maskCtx: null as CanvasRenderingContext2D | null,
|
||||
rgbCtx: null as CanvasRenderingContext2D | null,
|
||||
imgCtx: null as CanvasRenderingContext2D | null
|
||||
}))
|
||||
|
||||
const mockStore: MaskEditorStoreState = {
|
||||
get maskCanvas() {
|
||||
return mockRefs.maskCanvas
|
||||
},
|
||||
set maskCanvas(val) {
|
||||
mockRefs.maskCanvas = val
|
||||
},
|
||||
get rgbCanvas() {
|
||||
return mockRefs.rgbCanvas
|
||||
},
|
||||
set rgbCanvas(val) {
|
||||
mockRefs.rgbCanvas = val
|
||||
},
|
||||
get imgCanvas() {
|
||||
return mockRefs.imgCanvas
|
||||
},
|
||||
set imgCanvas(val) {
|
||||
mockRefs.imgCanvas = val
|
||||
},
|
||||
get maskCtx() {
|
||||
return mockRefs.maskCtx
|
||||
},
|
||||
set maskCtx(val) {
|
||||
mockRefs.maskCtx = val
|
||||
},
|
||||
get rgbCtx() {
|
||||
return mockRefs.rgbCtx
|
||||
},
|
||||
set rgbCtx(val) {
|
||||
mockRefs.rgbCtx = val
|
||||
},
|
||||
get imgCtx() {
|
||||
return mockRefs.imgCtx
|
||||
},
|
||||
set imgCtx(val) {
|
||||
mockRefs.imgCtx = val
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
// Mock ImageBitmap for test environment
|
||||
// Mock ImageBitmap using safe global augmentation pattern
|
||||
if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
globalThis.ImageBitmap = class ImageBitmap {
|
||||
width: number
|
||||
@@ -28,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
this.height = height
|
||||
}
|
||||
close() {}
|
||||
} as any
|
||||
} as unknown as typeof globalThis.ImageBitmap
|
||||
}
|
||||
|
||||
describe('useCanvasHistory', () => {
|
||||
@@ -43,9 +90,8 @@ describe('useCanvasHistory', () => {
|
||||
return rafCallCount
|
||||
}
|
||||
)
|
||||
vi.stubGlobal('alert', () => {})
|
||||
|
||||
const createMockImageData = () => {
|
||||
const createMockImageData = (): ImageData => {
|
||||
return {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
@@ -53,34 +99,43 @@ describe('useCanvasHistory', () => {
|
||||
} as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx = {
|
||||
// Mock contexts using explicit partial-cast pattern
|
||||
mockRefs.maskCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockRgbCtx = {
|
||||
mockRefs.rgbCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockMaskCanvas = {
|
||||
mockRefs.imgCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
// Mock canvases using explicit partial-cast pattern
|
||||
mockRefs.maskCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
mockRgbCanvas = {
|
||||
mockRefs.rgbCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
mockStore.rgbCanvas = mockRgbCanvas
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
mockStore.rgbCtx = mockRgbCtx
|
||||
mockRefs.imgCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
@@ -96,8 +151,14 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
})
|
||||
@@ -105,27 +166,47 @@ describe('useCanvasHistory', () => {
|
||||
it('should wait for canvas to be ready', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
|
||||
mockRefs.maskCanvas = {
|
||||
...mockRefs.maskCanvas,
|
||||
width: 0,
|
||||
height: 0
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
const history = useCanvasHistory()
|
||||
history.saveInitialState()
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
mockRefs.maskCanvas = {
|
||||
width: 100,
|
||||
height: 100
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
})
|
||||
|
||||
it('should wait for context to be ready', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
mockStore.maskCtx = null
|
||||
mockRefs.maskCtx = null
|
||||
|
||||
const history = useCanvasHistory()
|
||||
history.saveInitialState()
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
const createMockImageData = (): ImageData => {
|
||||
return {
|
||||
data: new Uint8ClampedArray(100 * 100 * 4),
|
||||
width: 100,
|
||||
height: 100
|
||||
} as ImageData
|
||||
}
|
||||
|
||||
mockRefs.maskCtx = {
|
||||
getImageData: vi.fn(() => createMockImageData()),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,13 +215,20 @@ describe('useCanvasHistory', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
@@ -184,8 +272,9 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not save state if context is missing', () => {
|
||||
@@ -193,15 +282,17 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
mockStore.maskCtx = null
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
const savedMaskCtx = mockRefs.maskCtx
|
||||
mockRefs.maskCtx = null
|
||||
vi.mocked(savedMaskCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
|
||||
|
||||
history.saveState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
|
||||
expect(savedMaskCtx!.getImageData).not.toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
mockRefs.maskCtx = savedMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,20 +305,27 @@ describe('useCanvasHistory', () => {
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(history.canUndo.value).toBe(false)
|
||||
expect(history.canRedo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should show alert when no undo states available', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
it('should not undo when no undo states available', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should undo multiple times', () => {
|
||||
@@ -249,16 +347,22 @@ describe('useCanvasHistory', () => {
|
||||
})
|
||||
|
||||
it('should not undo beyond first state', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
history.undo()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalled()
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,25 +374,33 @@ describe('useCanvasHistory', () => {
|
||||
history.saveState()
|
||||
history.undo()
|
||||
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
mockRgbCtx.putImageData.mockClear()
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.redo()
|
||||
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(history.canRedo.value).toBe(false)
|
||||
expect(history.canUndo.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should show alert when no redo states available', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
it('should not redo when no redo states available', () => {
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.redo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should redo multiple times', () => {
|
||||
@@ -314,7 +426,6 @@ describe('useCanvasHistory', () => {
|
||||
})
|
||||
|
||||
it('should not redo beyond last state', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert')
|
||||
const history = useCanvasHistory()
|
||||
|
||||
history.saveInitialState()
|
||||
@@ -322,9 +433,16 @@ describe('useCanvasHistory', () => {
|
||||
history.undo()
|
||||
|
||||
history.redo()
|
||||
|
||||
vi.mocked(mockRefs.maskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.redo()
|
||||
|
||||
expect(alertSpy).toHaveBeenCalled()
|
||||
expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -348,13 +466,15 @@ describe('useCanvasHistory', () => {
|
||||
history.saveInitialState()
|
||||
history.clearStates()
|
||||
|
||||
mockMaskCtx.getImageData.mockClear()
|
||||
mockRgbCtx.getImageData.mockClear()
|
||||
vi.mocked(mockRefs.maskCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.getImageData).mockClear()
|
||||
|
||||
history.saveInitialState()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled()
|
||||
expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -446,15 +566,17 @@ describe('useCanvasHistory', () => {
|
||||
history.saveInitialState()
|
||||
history.saveState()
|
||||
|
||||
mockStore.maskCtx = null
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
mockRgbCtx.putImageData.mockClear()
|
||||
const savedMaskCtx = mockRefs.maskCtx
|
||||
mockRefs.maskCtx = null
|
||||
vi.mocked(savedMaskCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear()
|
||||
vi.mocked(mockRefs.imgCtx!.putImageData).mockClear()
|
||||
|
||||
history.undo()
|
||||
|
||||
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
|
||||
expect(savedMaskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
mockRefs.maskCtx = savedMaskCtx
|
||||
})
|
||||
})
|
||||
|
||||
@@ -499,8 +621,12 @@ describe('useCanvasHistory', () => {
|
||||
})
|
||||
|
||||
it('should handle zero-sized canvas', () => {
|
||||
mockMaskCanvas.width = 0
|
||||
mockMaskCanvas.height = 0
|
||||
if (mockRefs.maskCanvas) {
|
||||
mockRefs.maskCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
}
|
||||
|
||||
const history = useCanvasHistory()
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
// Define the state interface for better readability
|
||||
interface CanvasState {
|
||||
mask: ImageData | ImageBitmap
|
||||
rgb: ImageData | ImageBitmap
|
||||
img: ImageData | ImageBitmap
|
||||
}
|
||||
|
||||
export function useCanvasHistory(maxStates = 20) {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const states = ref<
|
||||
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
|
||||
>([])
|
||||
const states = ref<CanvasState[]>([])
|
||||
const currentStateIndex = ref(-1)
|
||||
const initialized = ref(false)
|
||||
|
||||
@@ -22,22 +27,29 @@ export function useCanvasHistory(maxStates = 20) {
|
||||
})
|
||||
|
||||
const saveInitialState = () => {
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
const maskCanvas = store.maskCanvas
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
|
||||
|
||||
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
|
||||
// Ensure all 3 contexts and canvases are ready
|
||||
if (
|
||||
!maskCtx ||
|
||||
!rgbCtx ||
|
||||
!imgCtx ||
|
||||
!maskCanvas ||
|
||||
!rgbCanvas ||
|
||||
!imgCanvas
|
||||
) {
|
||||
requestAnimationFrame(saveInitialState)
|
||||
return
|
||||
}
|
||||
|
||||
if (!maskCanvas.width || !rgbCanvas.width) {
|
||||
if (!maskCanvas.width || !rgbCanvas.width || !imgCanvas.width) {
|
||||
requestAnimationFrame(saveInitialState)
|
||||
return
|
||||
}
|
||||
|
||||
states.value = []
|
||||
|
||||
// Capture all three layers
|
||||
const maskState = maskCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
@@ -50,35 +62,51 @@ export function useCanvasHistory(maxStates = 20) {
|
||||
rgbCanvas.width,
|
||||
rgbCanvas.height
|
||||
)
|
||||
states.value.push({ mask: maskState, rgb: rgbState })
|
||||
const imgState = imgCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
imgCanvas.width,
|
||||
imgCanvas.height
|
||||
)
|
||||
|
||||
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
|
||||
currentStateIndex.value = 0
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
const saveState = (
|
||||
providedMaskData?: ImageData | ImageBitmap,
|
||||
providedRgbData?: ImageData | ImageBitmap
|
||||
providedRgbData?: ImageData | ImageBitmap,
|
||||
providedImgData?: ImageData | ImageBitmap
|
||||
) => {
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
const maskCanvas = store.maskCanvas
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
|
||||
|
||||
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
|
||||
if (
|
||||
!maskCtx ||
|
||||
!rgbCtx ||
|
||||
!imgCtx ||
|
||||
!maskCanvas ||
|
||||
!rgbCanvas ||
|
||||
!imgCanvas
|
||||
)
|
||||
return
|
||||
|
||||
if (!initialized.value || currentStateIndex.value === -1) {
|
||||
saveInitialState()
|
||||
return
|
||||
}
|
||||
|
||||
// Clear redo history
|
||||
states.value = states.value.slice(0, currentStateIndex.value + 1)
|
||||
|
||||
let maskState: ImageData | ImageBitmap
|
||||
let rgbState: ImageData | ImageBitmap
|
||||
let imgState: ImageData | ImageBitmap
|
||||
|
||||
if (providedMaskData && providedRgbData) {
|
||||
if (providedMaskData && providedRgbData && providedImgData) {
|
||||
maskState = providedMaskData
|
||||
rgbState = providedRgbData
|
||||
imgState = providedImgData
|
||||
} else {
|
||||
maskState = maskCtx.getImageData(
|
||||
0,
|
||||
@@ -87,71 +115,84 @@ export function useCanvasHistory(maxStates = 20) {
|
||||
maskCanvas.height
|
||||
)
|
||||
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
|
||||
imgState = imgCtx.getImageData(0, 0, imgCanvas.width, imgCanvas.height)
|
||||
}
|
||||
|
||||
states.value.push({ mask: maskState, rgb: rgbState })
|
||||
states.value.push({ mask: maskState, rgb: rgbState, img: imgState })
|
||||
currentStateIndex.value++
|
||||
|
||||
// Maintain max history size and clean up memory
|
||||
if (states.value.length > maxStates) {
|
||||
const removed = states.value.shift()
|
||||
// Cleanup ImageBitmaps to avoid memory leaks
|
||||
if (removed) {
|
||||
if (removed.mask instanceof ImageBitmap) removed.mask.close()
|
||||
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
|
||||
cleanupState(removed)
|
||||
}
|
||||
currentStateIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
const undo = () => {
|
||||
if (!canUndo.value) {
|
||||
alert('No more undo states available')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canUndo.value) return
|
||||
currentStateIndex.value--
|
||||
restoreState(states.value[currentStateIndex.value])
|
||||
}
|
||||
|
||||
const redo = () => {
|
||||
if (!canRedo.value) {
|
||||
alert('No more redo states available')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canRedo.value) return
|
||||
currentStateIndex.value++
|
||||
restoreState(states.value[currentStateIndex.value])
|
||||
}
|
||||
|
||||
const restoreState = (state: {
|
||||
mask: ImageData | ImageBitmap
|
||||
rgb: ImageData | ImageBitmap
|
||||
}) => {
|
||||
const maskCtx = store.maskCtx
|
||||
const rgbCtx = store.rgbCtx
|
||||
if (!maskCtx || !rgbCtx) return
|
||||
const restoreState = (state: CanvasState) => {
|
||||
const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store
|
||||
if (
|
||||
!maskCtx ||
|
||||
!rgbCtx ||
|
||||
!imgCtx ||
|
||||
!maskCanvas ||
|
||||
!rgbCanvas ||
|
||||
!imgCanvas
|
||||
)
|
||||
return
|
||||
|
||||
if (state.mask instanceof ImageBitmap) {
|
||||
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
|
||||
maskCtx.drawImage(state.mask, 0, 0)
|
||||
} else {
|
||||
maskCtx.putImageData(state.mask, 0, 0)
|
||||
// Update canvas dimensions to match state (handles rotation undo/redo)
|
||||
const refData = state.mask
|
||||
const newWidth = refData.width
|
||||
const newHeight = refData.height
|
||||
|
||||
if (maskCanvas.width !== newWidth || maskCanvas.height !== newHeight) {
|
||||
maskCanvas.width = newWidth
|
||||
maskCanvas.height = newHeight
|
||||
rgbCanvas.width = newWidth
|
||||
rgbCanvas.height = newHeight
|
||||
imgCanvas.width = newWidth
|
||||
imgCanvas.height = newHeight
|
||||
}
|
||||
|
||||
if (state.rgb instanceof ImageBitmap) {
|
||||
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
|
||||
rgbCtx.drawImage(state.rgb, 0, 0)
|
||||
} else {
|
||||
rgbCtx.putImageData(state.rgb, 0, 0)
|
||||
}
|
||||
const layers = [
|
||||
{ ctx: maskCtx, data: state.mask },
|
||||
{ ctx: rgbCtx, data: state.rgb },
|
||||
{ ctx: imgCtx, data: state.img }
|
||||
]
|
||||
|
||||
layers.forEach(({ ctx, data }) => {
|
||||
if (data instanceof ImageBitmap) {
|
||||
ctx.clearRect(0, 0, data.width, data.height)
|
||||
ctx.drawImage(data, 0, 0)
|
||||
} else {
|
||||
ctx.putImageData(data, 0, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cleanupState = (state: CanvasState) => {
|
||||
if (state.mask instanceof ImageBitmap) state.mask.close()
|
||||
if (state.rgb instanceof ImageBitmap) state.rgb.close()
|
||||
if (state.img instanceof ImageBitmap) state.img.close()
|
||||
}
|
||||
|
||||
const clearStates = () => {
|
||||
// Cleanup bitmaps
|
||||
states.value.forEach((state) => {
|
||||
if (state.mask instanceof ImageBitmap) state.mask.close()
|
||||
if (state.rgb instanceof ImageBitmap) state.rgb.close()
|
||||
})
|
||||
states.value.forEach(cleanupState)
|
||||
states.value = []
|
||||
currentStateIndex.value = -1
|
||||
initialized.value = false
|
||||
|
||||
683
src/composables/maskeditor/useCanvasTransform.test.ts
Normal file
683
src/composables/maskeditor/useCanvasTransform.test.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
|
||||
interface IMockCanvas {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface IMockContext {
|
||||
getImageData: ReturnType<typeof vi.fn>
|
||||
putImageData: ReturnType<typeof vi.fn>
|
||||
clearRect: ReturnType<typeof vi.fn>
|
||||
drawImage: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface IMockCanvasHistory {
|
||||
saveState: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface IMockStore {
|
||||
maskCanvas: IMockCanvas | null
|
||||
rgbCanvas: IMockCanvas | null
|
||||
imgCanvas: IMockCanvas | null
|
||||
maskCtx: IMockContext | null
|
||||
rgbCtx: IMockContext | null
|
||||
imgCtx: IMockContext | null
|
||||
tgpuRoot: unknown
|
||||
canvasHistory: IMockCanvasHistory
|
||||
gpuTexturesNeedRecreation: boolean
|
||||
gpuTextureWidth: number
|
||||
gpuTextureHeight: number
|
||||
pendingGPUMaskData: Uint8ClampedArray | null
|
||||
pendingGPURgbData: Uint8ClampedArray | null
|
||||
}
|
||||
|
||||
const { mockStore, mockCanvasHistory } = vi.hoisted(() => {
|
||||
const mockCanvasHistory: IMockCanvasHistory = {
|
||||
saveState: vi.fn()
|
||||
}
|
||||
|
||||
const mockStore: IMockStore = {
|
||||
maskCanvas: null,
|
||||
rgbCanvas: null,
|
||||
imgCanvas: null,
|
||||
maskCtx: null,
|
||||
rgbCtx: null,
|
||||
imgCtx: null,
|
||||
tgpuRoot: null,
|
||||
canvasHistory: mockCanvasHistory,
|
||||
gpuTexturesNeedRecreation: false,
|
||||
gpuTextureWidth: 0,
|
||||
gpuTextureHeight: 0,
|
||||
pendingGPUMaskData: null,
|
||||
pendingGPURgbData: null
|
||||
}
|
||||
|
||||
return { mockStore, mockCanvasHistory }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
// Mock ImageData with improved type safety
|
||||
if (typeof globalThis.ImageData === 'undefined') {
|
||||
globalThis.ImageData = class ImageData {
|
||||
data: Uint8ClampedArray
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(
|
||||
dataOrWidth: Uint8ClampedArray | number,
|
||||
widthOrHeight?: number,
|
||||
height?: number
|
||||
) {
|
||||
if (dataOrWidth instanceof Uint8ClampedArray) {
|
||||
// Constructor overload: new ImageData(data, width, height)
|
||||
if (widthOrHeight === undefined || height === undefined) {
|
||||
throw new Error(
|
||||
'ImageData constructor requires width and height when data is provided'
|
||||
)
|
||||
}
|
||||
this.data = dataOrWidth
|
||||
this.width = widthOrHeight
|
||||
this.height = height
|
||||
} else {
|
||||
// Constructor overload: new ImageData(width, height)
|
||||
if (widthOrHeight === undefined) {
|
||||
throw new Error(
|
||||
'ImageData constructor requires height when width is provided'
|
||||
)
|
||||
}
|
||||
this.width = dataOrWidth
|
||||
this.height = widthOrHeight
|
||||
this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4)
|
||||
}
|
||||
}
|
||||
} as unknown as typeof globalThis.ImageData
|
||||
}
|
||||
|
||||
// Mock ImageBitmap for test environment using safe type casting
|
||||
if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
globalThis.ImageBitmap = class ImageBitmap {
|
||||
width: number
|
||||
height: number
|
||||
constructor(width = 100, height = 100) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
close() {}
|
||||
} as unknown as typeof globalThis.ImageBitmap
|
||||
}
|
||||
|
||||
describe('useCanvasTransform', () => {
|
||||
let mockMaskCanvas: IMockCanvas
|
||||
let mockRgbCanvas: IMockCanvas
|
||||
let mockImgCanvas: IMockCanvas
|
||||
let mockMaskCtx: IMockContext
|
||||
let mockRgbCtx: IMockContext
|
||||
let mockImgCtx: IMockContext
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
const createMockImageData = (width: number, height: number) => {
|
||||
const data = new Uint8ClampedArray(width * height * 4)
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = 255 // R
|
||||
data[i + 1] = 0 // G
|
||||
data[i + 2] = 0 // B
|
||||
data[i + 3] = 255 // A
|
||||
}
|
||||
return {
|
||||
data,
|
||||
width,
|
||||
height
|
||||
} as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx = {
|
||||
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockRgbCtx = {
|
||||
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockImgCtx = {
|
||||
getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
|
||||
mockMaskCanvas = {
|
||||
width: 100,
|
||||
height: 50
|
||||
}
|
||||
|
||||
mockRgbCanvas = {
|
||||
width: 100,
|
||||
height: 50
|
||||
}
|
||||
|
||||
mockImgCanvas = {
|
||||
width: 100,
|
||||
height: 50
|
||||
}
|
||||
|
||||
mockStore.maskCanvas = mockMaskCanvas
|
||||
mockStore.rgbCanvas = mockRgbCanvas
|
||||
mockStore.imgCanvas = mockImgCanvas
|
||||
mockStore.maskCtx = mockMaskCtx
|
||||
mockStore.rgbCtx = mockRgbCtx
|
||||
mockStore.imgCtx = mockImgCtx
|
||||
mockStore.tgpuRoot = null
|
||||
mockStore.gpuTexturesNeedRecreation = false
|
||||
mockStore.gpuTextureWidth = 0
|
||||
mockStore.gpuTextureHeight = 0
|
||||
mockStore.pendingGPUMaskData = null
|
||||
mockStore.pendingGPURgbData = null
|
||||
})
|
||||
|
||||
describe('rotateClockwise', () => {
|
||||
it('should rotate canvas 90 degrees clockwise', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(50)
|
||||
expect(mockMaskCanvas.height).toBe(100)
|
||||
expect(mockRgbCanvas.width).toBe(50)
|
||||
expect(mockRgbCanvas.height).toBe(100)
|
||||
expect(mockImgCanvas.width).toBe(50)
|
||||
expect(mockImgCanvas.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should call getImageData with original dimensions', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
expect(mockImgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
})
|
||||
|
||||
it('should call putImageData with rotated data', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockImgCtx.putImageData).toHaveBeenCalled()
|
||||
|
||||
const maskCall = mockMaskCtx.putImageData.mock.calls[0][0]
|
||||
expect(maskCall.width).toBe(50)
|
||||
expect(maskCall.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should save transformed state to history', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
|
||||
const savedArgs = mockCanvasHistory.saveState.mock.calls[0]
|
||||
expect(savedArgs).toHaveLength(3)
|
||||
|
||||
expect(savedArgs[0].width).toBe(50)
|
||||
expect(savedArgs[0].height).toBe(100)
|
||||
expect(savedArgs[1].width).toBe(50)
|
||||
expect(savedArgs[1].height).toBe(100)
|
||||
expect(savedArgs[2].width).toBe(50)
|
||||
expect(savedArgs[2].height).toBe(100)
|
||||
})
|
||||
|
||||
it('should log error when canvas contexts not ready', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[useCanvasTransform] Canvas contexts not ready'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle GPU texture recreation when GPU is active', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(50)
|
||||
expect(mockStore.gpuTextureHeight).toBe(100)
|
||||
})
|
||||
|
||||
it('should not recreate GPU textures when GPU is inactive', async () => {
|
||||
mockStore.tgpuRoot = null
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(false)
|
||||
})
|
||||
|
||||
it('should correctly rotate pixels clockwise at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After clockwise rotation:
|
||||
// New TL should be old BL (Blue)
|
||||
expect(result.data[0]).toBe(0) // R
|
||||
expect(result.data[1]).toBe(0) // G
|
||||
expect(result.data[2]).toBe(255) // B
|
||||
expect(result.data[3]).toBe(255) // A
|
||||
|
||||
// New TR should be old TL (Red)
|
||||
expect(result.data[4]).toBe(255) // R
|
||||
expect(result.data[5]).toBe(0) // G
|
||||
expect(result.data[6]).toBe(0) // B
|
||||
expect(result.data[7]).toBe(255) // A
|
||||
|
||||
// New BL should be old BR (Yellow)
|
||||
expect(result.data[8]).toBe(255) // R
|
||||
expect(result.data[9]).toBe(255) // G
|
||||
expect(result.data[10]).toBe(0) // B
|
||||
expect(result.data[11]).toBe(255) // A
|
||||
|
||||
// New BR should be old TR (Green)
|
||||
expect(result.data[12]).toBe(0) // R
|
||||
expect(result.data[13]).toBe(255) // G
|
||||
expect(result.data[14]).toBe(0) // B
|
||||
expect(result.data[15]).toBe(255) // A
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateCounterclockwise', () => {
|
||||
it('should rotate canvas 90 degrees counterclockwise', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateCounterclockwise()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(50)
|
||||
expect(mockMaskCanvas.height).toBe(100)
|
||||
})
|
||||
|
||||
it('should call getImageData with original dimensions', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateCounterclockwise()
|
||||
|
||||
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50)
|
||||
})
|
||||
|
||||
it('should correctly rotate pixels counterclockwise at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateCounterclockwise()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After counterclockwise rotation:
|
||||
// New TL should be old TR (Green)
|
||||
expect(result.data[0]).toBe(0) // R
|
||||
expect(result.data[1]).toBe(255) // G
|
||||
expect(result.data[2]).toBe(0) // B
|
||||
expect(result.data[3]).toBe(255) // A
|
||||
|
||||
// New TR should be old BR (Yellow)
|
||||
expect(result.data[4]).toBe(255) // R
|
||||
expect(result.data[5]).toBe(255) // G
|
||||
expect(result.data[6]).toBe(0) // B
|
||||
expect(result.data[7]).toBe(255) // A
|
||||
|
||||
// New BL should be old TL (Red)
|
||||
expect(result.data[8]).toBe(255) // R
|
||||
expect(result.data[9]).toBe(0) // G
|
||||
expect(result.data[10]).toBe(0) // B
|
||||
expect(result.data[11]).toBe(255) // A
|
||||
|
||||
// New BR should be old BL (Blue)
|
||||
expect(result.data[12]).toBe(0) // R
|
||||
expect(result.data[13]).toBe(0) // G
|
||||
expect(result.data[14]).toBe(255) // B
|
||||
expect(result.data[15]).toBe(255) // A
|
||||
})
|
||||
|
||||
it('should produce different result than clockwise rotation', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
|
||||
const createAsymmetricImageData = (width: number, height: number) => {
|
||||
const data = new Uint8ClampedArray(width * height * 4)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4
|
||||
if (x < width / 2 && y < height / 2) {
|
||||
data[i] = 255
|
||||
data[i + 3] = 255
|
||||
} else {
|
||||
data[i + 3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, width, height } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
|
||||
await transform.rotateCounterclockwise()
|
||||
const ccwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
mockMaskCanvas.width = 100
|
||||
mockMaskCanvas.height = 50
|
||||
mockMaskCtx.putImageData.mockClear()
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50))
|
||||
await transform.rotateClockwise()
|
||||
const cwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
let pixelDifferences = 0
|
||||
for (let i = 0; i < ccwResult.data.length; i++) {
|
||||
if (ccwResult.data[i] !== cwResult.data[i]) {
|
||||
pixelDifferences++
|
||||
}
|
||||
}
|
||||
|
||||
expect(pixelDifferences).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mirrorHorizontal', () => {
|
||||
it('should mirror canvas horizontally', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorHorizontal()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(100)
|
||||
expect(mockMaskCanvas.height).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle GPU texture recreation when GPU is active', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorHorizontal()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(100)
|
||||
expect(mockStore.gpuTextureHeight).toBe(50)
|
||||
})
|
||||
|
||||
it('should correctly flip pixels horizontally at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorHorizontal()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After horizontal flip:
|
||||
// New TL should be old TR (Green)
|
||||
expect(result.data[0]).toBe(0)
|
||||
expect(result.data[1]).toBe(255)
|
||||
// New TR should be old TL (Red)
|
||||
expect(result.data[4]).toBe(255)
|
||||
expect(result.data[5]).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mirrorVertical', () => {
|
||||
it('should mirror canvas vertically', async () => {
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
expect(mockMaskCanvas.width).toBe(100)
|
||||
expect(mockMaskCanvas.height).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle GPU texture recreation when GPU is active', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(100)
|
||||
expect(mockStore.gpuTextureHeight).toBe(50)
|
||||
})
|
||||
|
||||
it('should correctly flip pixels vertically at pixel level', async () => {
|
||||
mockMaskCanvas.width = 2
|
||||
mockMaskCanvas.height = 2
|
||||
|
||||
const createTestPattern = () => {
|
||||
const data = new Uint8ClampedArray(2 * 2 * 4)
|
||||
// TL (0,0): Red
|
||||
data[0] = 255
|
||||
data[1] = 0
|
||||
data[2] = 0
|
||||
data[3] = 255
|
||||
// TR (1,0): Green
|
||||
data[4] = 0
|
||||
data[5] = 255
|
||||
data[6] = 0
|
||||
data[7] = 255
|
||||
// BL (0,1): Blue
|
||||
data[8] = 0
|
||||
data[9] = 0
|
||||
data[10] = 255
|
||||
data[11] = 255
|
||||
// BR (1,1): Yellow
|
||||
data[12] = 255
|
||||
data[13] = 255
|
||||
data[14] = 0
|
||||
data[15] = 255
|
||||
return { data, width: 2, height: 2 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createTestPattern())
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData
|
||||
|
||||
// After vertical flip:
|
||||
// New TL should be old BL (Blue)
|
||||
expect(result.data[0]).toBe(0) // R
|
||||
expect(result.data[1]).toBe(0) // G
|
||||
expect(result.data[2]).toBe(255) // B
|
||||
expect(result.data[3]).toBe(255) // A
|
||||
|
||||
// New TR should be old BR (Yellow)
|
||||
expect(result.data[4]).toBe(255) // R
|
||||
expect(result.data[5]).toBe(255) // G
|
||||
expect(result.data[6]).toBe(0) // B
|
||||
expect(result.data[7]).toBe(255) // A
|
||||
|
||||
// New BL should be old TL (Red)
|
||||
expect(result.data[8]).toBe(255) // R
|
||||
expect(result.data[9]).toBe(0) // G
|
||||
expect(result.data[10]).toBe(0) // B
|
||||
expect(result.data[11]).toBe(255) // A
|
||||
|
||||
// New BR should be old TR (Green)
|
||||
expect(result.data[12]).toBe(0) // R
|
||||
expect(result.data[13]).toBe(255) // G
|
||||
expect(result.data[14]).toBe(0) // B
|
||||
expect(result.data[15]).toBe(255) // A
|
||||
})
|
||||
|
||||
it('should log error when canvas contexts not ready', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.mirrorVertical()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[useCanvasTransform] Canvas contexts not ready'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GPU integration', () => {
|
||||
it('should set GPU recreation flags for rotation', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
mockMaskCanvas.width = 100
|
||||
mockMaskCanvas.height = 50
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
expect(mockStore.gpuTexturesNeedRecreation).toBe(true)
|
||||
expect(mockStore.gpuTextureWidth).toBe(50)
|
||||
expect(mockStore.gpuTextureHeight).toBe(100)
|
||||
expect(mockStore.pendingGPUMaskData!.length).toBe(50 * 100 * 4)
|
||||
expect(mockStore.pendingGPURgbData!.length).toBe(50 * 100 * 4)
|
||||
})
|
||||
|
||||
it('should premultiply alpha when preparing GPU data', async () => {
|
||||
mockStore.tgpuRoot = {}
|
||||
mockMaskCanvas.width = 1
|
||||
mockMaskCanvas.height = 1
|
||||
|
||||
// Create 1x1 ImageData with semi-transparent pixel
|
||||
const createSemiTransparentImageData = () => {
|
||||
const data = new Uint8ClampedArray(1 * 1 * 4)
|
||||
data[0] = 200 // R
|
||||
data[1] = 100 // G
|
||||
data[2] = 50 // B
|
||||
data[3] = 128 // A (50% opacity)
|
||||
return { data, width: 1, height: 1 } as ImageData
|
||||
}
|
||||
|
||||
mockMaskCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
|
||||
mockRgbCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
|
||||
mockImgCtx.getImageData = vi.fn(() => createSemiTransparentImageData())
|
||||
|
||||
const transform = useCanvasTransform()
|
||||
await transform.rotateClockwise()
|
||||
|
||||
// Verify pendingGPUMaskData contains premultiplied values
|
||||
expect(mockStore.pendingGPUMaskData).not.toBeNull()
|
||||
const maskData = mockStore.pendingGPUMaskData!
|
||||
|
||||
// Expected premultiplied values: RGB * alpha / 255
|
||||
// R: 200 * 128 / 255 ≈ 100
|
||||
// G: 100 * 128 / 255 ≈ 50
|
||||
// B: 50 * 128 / 255 ≈ 25
|
||||
// A: 128 (preserved)
|
||||
expect(maskData[0]).toBeCloseTo(100, 0) // R premultiplied
|
||||
expect(maskData[1]).toBeCloseTo(50, 0) // G premultiplied
|
||||
expect(maskData[2]).toBeCloseTo(25, 0) // B premultiplied
|
||||
expect(maskData[3]).toBe(128) // A preserved
|
||||
|
||||
// Also verify RGB canvas data
|
||||
expect(mockStore.pendingGPURgbData).not.toBeNull()
|
||||
const rgbData = mockStore.pendingGPURgbData!
|
||||
expect(rgbData[0]).toBeCloseTo(100, 0)
|
||||
expect(rgbData[1]).toBeCloseTo(50, 0)
|
||||
expect(rgbData[2]).toBeCloseTo(25, 0)
|
||||
expect(rgbData[3]).toBe(128)
|
||||
})
|
||||
})
|
||||
})
|
||||
359
src/composables/maskeditor/useCanvasTransform.ts
Normal file
359
src/composables/maskeditor/useCanvasTransform.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
/**
|
||||
* Composable for canvas transformation operations (rotate, mirror)
|
||||
*/
|
||||
export function useCanvasTransform() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
/**
|
||||
* Rotates a canvas 90 degrees clockwise or counter-clockwise
|
||||
*/
|
||||
const rotateCanvas = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
clockwise: boolean
|
||||
): ImageData => {
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
// Get current canvas data
|
||||
const sourceData = ctx.getImageData(0, 0, width, height)
|
||||
|
||||
// Create new ImageData with swapped dimensions
|
||||
const rotatedData = new ImageData(height, width)
|
||||
const src = sourceData.data
|
||||
const dst = rotatedData.data
|
||||
|
||||
// Rotate pixel by pixel
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = (y * width + x) * 4
|
||||
|
||||
// Calculate destination coordinates
|
||||
let dstX: number, dstY: number
|
||||
if (clockwise) {
|
||||
// Rotate 90° clockwise: (x,y) -> (height-1-y, x)
|
||||
dstX = height - 1 - y
|
||||
dstY = x
|
||||
} else {
|
||||
// Rotate 90° counter-clockwise: (x,y) -> (y, width-1-x)
|
||||
dstX = y
|
||||
dstY = width - 1 - x
|
||||
}
|
||||
|
||||
const dstIdx = (dstY * height + dstX) * 4
|
||||
|
||||
// Copy RGBA values
|
||||
dst[dstIdx] = src[srcIdx]
|
||||
dst[dstIdx + 1] = src[srcIdx + 1]
|
||||
dst[dstIdx + 2] = src[srcIdx + 2]
|
||||
dst[dstIdx + 3] = src[srcIdx + 3]
|
||||
}
|
||||
}
|
||||
|
||||
return rotatedData
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a canvas horizontally or vertically
|
||||
*/
|
||||
const mirrorCanvas = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
horizontal: boolean
|
||||
): ImageData => {
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
// Get current canvas data
|
||||
const sourceData = ctx.getImageData(0, 0, width, height)
|
||||
const mirroredData = new ImageData(width, height)
|
||||
const src = sourceData.data
|
||||
const dst = mirroredData.data
|
||||
|
||||
// Mirror pixel by pixel
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = (y * width + x) * 4
|
||||
|
||||
// Calculate destination coordinates
|
||||
let dstX: number, dstY: number
|
||||
if (horizontal) {
|
||||
// Mirror horizontally: flip X axis
|
||||
dstX = width - 1 - x
|
||||
dstY = y
|
||||
} else {
|
||||
// Mirror vertically: flip Y axis
|
||||
dstX = x
|
||||
dstY = height - 1 - y
|
||||
}
|
||||
|
||||
const dstIdx = (dstY * width + dstX) * 4
|
||||
|
||||
// Copy RGBA values
|
||||
dst[dstIdx] = src[srcIdx]
|
||||
dst[dstIdx + 1] = src[srcIdx + 1]
|
||||
dst[dstIdx + 2] = src[srcIdx + 2]
|
||||
dst[dstIdx + 3] = src[srcIdx + 3]
|
||||
}
|
||||
}
|
||||
|
||||
return mirroredData
|
||||
}
|
||||
|
||||
/**
|
||||
* Premultiplies alpha for GPU upload
|
||||
*/
|
||||
const premultiplyData = (data: Uint8ClampedArray): void => {
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3] / 255
|
||||
data[i] = Math.round(data[i] * a)
|
||||
data[i + 1] = Math.round(data[i + 1] * a)
|
||||
data[i + 2] = Math.round(data[i + 2] * a)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates and updates GPU textures after transformation
|
||||
* This is required because GPU textures have immutable dimensions
|
||||
*/
|
||||
const recreateGPUTextures = async (
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> => {
|
||||
if (
|
||||
!store.tgpuRoot ||
|
||||
!store.maskCanvas ||
|
||||
!store.rgbCanvas ||
|
||||
!store.maskCtx ||
|
||||
!store.rgbCtx
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get references to GPU resources from useBrushDrawing
|
||||
// These are stored as module-level variables in useBrushDrawing
|
||||
// We need to trigger a reinitialization through the store
|
||||
|
||||
// Signal to useBrushDrawing that textures need recreation
|
||||
store.gpuTexturesNeedRecreation = true
|
||||
store.gpuTextureWidth = width
|
||||
store.gpuTextureHeight = height
|
||||
|
||||
// Get current canvas data
|
||||
const maskImageData = store.maskCtx.getImageData(0, 0, width, height)
|
||||
const rgbImageData = store.rgbCtx.getImageData(0, 0, width, height)
|
||||
|
||||
// Create new Uint8ClampedArray with ArrayBuffer (not SharedArrayBuffer)
|
||||
// This ensures compatibility with WebGPU writeTexture
|
||||
const maskData = new Uint8ClampedArray(
|
||||
new ArrayBuffer(maskImageData.data.length)
|
||||
)
|
||||
const rgbData = new Uint8ClampedArray(
|
||||
new ArrayBuffer(rgbImageData.data.length)
|
||||
)
|
||||
|
||||
// Copy data
|
||||
maskData.set(maskImageData.data)
|
||||
rgbData.set(rgbImageData.data)
|
||||
|
||||
// Runtime check to ensure we have ArrayBuffer backing
|
||||
if (
|
||||
maskData.buffer instanceof SharedArrayBuffer ||
|
||||
rgbData.buffer instanceof SharedArrayBuffer
|
||||
) {
|
||||
console.error(
|
||||
'[useCanvasTransform] SharedArrayBuffer detected, WebGPU writeTexture will fail'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Premultiply alpha for GPU
|
||||
premultiplyData(maskData)
|
||||
premultiplyData(rgbData)
|
||||
|
||||
// Store the premultiplied data for useBrushDrawing to pick up
|
||||
store.pendingGPUMaskData = maskData
|
||||
store.pendingGPURgbData = rgbData
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates all canvas layers 90 degrees clockwise and updates GPU
|
||||
*/
|
||||
const rotateClockwise = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Store original dimensions
|
||||
const origWidth = maskCanvas.width
|
||||
const origHeight = maskCanvas.height
|
||||
|
||||
// Rotate all three layers clockwise
|
||||
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, true)
|
||||
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, true)
|
||||
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, true)
|
||||
|
||||
// Update canvas dimensions (swap width/height)
|
||||
maskCanvas.width = origHeight
|
||||
maskCanvas.height = origWidth
|
||||
rgbCanvas.width = origHeight
|
||||
rgbCanvas.height = origWidth
|
||||
imgCanvas.width = origHeight
|
||||
imgCanvas.height = origWidth
|
||||
|
||||
// Apply rotated data
|
||||
maskCtx.putImageData(rotatedMask, 0, 0)
|
||||
rgbCtx.putImageData(rotatedRgb, 0, 0)
|
||||
imgCtx.putImageData(rotatedImg, 0, 0)
|
||||
|
||||
// Recreate GPU textures with new dimensions if GPU is active
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(origHeight, origWidth)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates all canvas layers 90 degrees counter-clockwise and updates GPU
|
||||
*/
|
||||
const rotateCounterclockwise = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Store original dimensions
|
||||
const origWidth = maskCanvas.width
|
||||
const origHeight = maskCanvas.height
|
||||
|
||||
// Rotate all three layers counter-clockwise
|
||||
const rotatedMask = rotateCanvas(maskCtx, maskCanvas, false)
|
||||
const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, false)
|
||||
const rotatedImg = rotateCanvas(imgCtx, imgCanvas, false)
|
||||
|
||||
// Update canvas dimensions (swap width/height)
|
||||
maskCanvas.width = origHeight
|
||||
maskCanvas.height = origWidth
|
||||
rgbCanvas.width = origHeight
|
||||
rgbCanvas.height = origWidth
|
||||
imgCanvas.width = origHeight
|
||||
imgCanvas.height = origWidth
|
||||
|
||||
// Apply rotated data
|
||||
maskCtx.putImageData(rotatedMask, 0, 0)
|
||||
rgbCtx.putImageData(rotatedRgb, 0, 0)
|
||||
imgCtx.putImageData(rotatedImg, 0, 0)
|
||||
|
||||
// Recreate GPU textures with new dimensions if GPU is active
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(origHeight, origWidth)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors all canvas layers horizontally and updates GPU
|
||||
*/
|
||||
const mirrorHorizontal = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror all three layers horizontally
|
||||
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, true)
|
||||
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, true)
|
||||
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, true)
|
||||
|
||||
// Apply mirrored data (dimensions stay the same)
|
||||
maskCtx.putImageData(mirroredMask, 0, 0)
|
||||
rgbCtx.putImageData(mirroredRgb, 0, 0)
|
||||
imgCtx.putImageData(mirroredImg, 0, 0)
|
||||
|
||||
// Update GPU textures if GPU is active (dimensions unchanged, just data)
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors all canvas layers vertically and updates GPU
|
||||
*/
|
||||
const mirrorVertical = async (): Promise<void> => {
|
||||
const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store
|
||||
|
||||
if (
|
||||
!maskCanvas ||
|
||||
!maskCtx ||
|
||||
!rgbCanvas ||
|
||||
!rgbCtx ||
|
||||
!imgCanvas ||
|
||||
!imgCtx
|
||||
) {
|
||||
console.error('[useCanvasTransform] Canvas contexts not ready')
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror all three layers vertically
|
||||
const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, false)
|
||||
const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, false)
|
||||
const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, false)
|
||||
|
||||
// Apply mirrored data (dimensions stay the same)
|
||||
maskCtx.putImageData(mirroredMask, 0, 0)
|
||||
rgbCtx.putImageData(mirroredRgb, 0, 0)
|
||||
imgCtx.putImageData(mirroredImg, 0, 0)
|
||||
|
||||
// Update GPU textures if GPU is active (dimensions unchanged, just data)
|
||||
if (store.tgpuRoot) {
|
||||
await recreateGPUTextures(maskCanvas.width, maskCanvas.height)
|
||||
}
|
||||
|
||||
// Save to history
|
||||
store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg)
|
||||
}
|
||||
|
||||
return {
|
||||
rotateClockwise,
|
||||
rotateCounterclockwise,
|
||||
mirrorHorizontal,
|
||||
mirrorVertical
|
||||
}
|
||||
}
|
||||
@@ -1664,31 +1664,41 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
model: 'gemini-2.5-pro-preview-05-06',
|
||||
expected: creditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-pro',
|
||||
expected: creditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-3-pro-preview',
|
||||
expected: creditsListLabel([0.002, 0.012], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-flash-preview-04-17',
|
||||
expected: creditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-flash',
|
||||
expected: creditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{ model: 'unknown-gemini-model', expected: 'Token-based' }
|
||||
@@ -1702,16 +1712,6 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return per-second pricing for Gemini Veo models', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('GeminiNode', [
|
||||
{ name: 'model', value: 'veo-2.0-generate-001' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(creditsLabel(0.5, { suffix: '/second' }))
|
||||
})
|
||||
|
||||
it('should return fallback for GeminiNode without model widget', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('GeminiNode', [])
|
||||
@@ -1737,73 +1737,97 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
model: 'o4-mini',
|
||||
expected: creditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1-pro',
|
||||
expected: creditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1',
|
||||
expected: creditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o3-mini',
|
||||
expected: creditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o3',
|
||||
expected: creditsListLabel([0.01, 0.04], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
expected: creditsListLabel([0.0025, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-nano',
|
||||
expected: creditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-mini',
|
||||
expected: creditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1',
|
||||
expected: creditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-5-nano',
|
||||
expected: creditsListLabel([0.00005, 0.0004], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-5-mini',
|
||||
expected: creditsListLabel([0.00025, 0.002], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-5',
|
||||
expected: creditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -1824,37 +1848,49 @@ describe('useNodePricing', () => {
|
||||
{
|
||||
model: 'gpt-4.1-nano-test',
|
||||
expected: creditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-mini-test',
|
||||
expected: creditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1-test',
|
||||
expected: creditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1-pro-test',
|
||||
expected: creditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o1-test',
|
||||
expected: creditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
model: 'o3-mini-test',
|
||||
expected: creditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
},
|
||||
{ model: 'unknown-model', expected: 'Token-based' }
|
||||
|
||||
@@ -1823,28 +1823,35 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
// Google Veo video generation
|
||||
if (model.includes('veo-2.0')) {
|
||||
return formatCreditsLabel(0.5, { suffix: '/second' })
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-flash')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-3-pro-preview')) {
|
||||
return formatCreditsListLabel([0.002, 0.012], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
@@ -1899,51 +1906,75 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
|
||||
if (model.includes('o4-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o1-pro')) {
|
||||
return formatCreditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o1')) {
|
||||
return formatCreditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o3-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o3')) {
|
||||
return formatCreditsListLabel([0.01, 0.04], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4o')) {
|
||||
return formatCreditsListLabel([0.0025, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-nano')) {
|
||||
return formatCreditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-mini')) {
|
||||
return formatCreditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1')) {
|
||||
return formatCreditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-5-nano')) {
|
||||
return formatCreditsListLabel([0.00005, 0.0004], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-5-mini')) {
|
||||
return formatCreditsListLabel([0.00025, 0.002], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-5')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
}
|
||||
return 'Token-based'
|
||||
@@ -2101,6 +2132,267 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
LtxvApiImageToVideo: {
|
||||
displayPrice: ltxvPricingCalculator
|
||||
},
|
||||
WanReferenceVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const sizeWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !sizeWidget) {
|
||||
return formatCreditsRangeLabel(0.7, 1.5, {
|
||||
note: '(varies with size & duration)'
|
||||
})
|
||||
}
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const sizeStr = String(sizeWidget.value).toLowerCase()
|
||||
|
||||
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
|
||||
const inputMin = 2 * rate
|
||||
const inputMax = 5 * rate
|
||||
const outputPrice = seconds * rate
|
||||
|
||||
const minTotal = inputMin + outputPrice
|
||||
const maxTotal = inputMax + outputPrice
|
||||
|
||||
return formatCreditsRangeLabel(minTotal, maxTotal)
|
||||
}
|
||||
},
|
||||
Vidu2TextToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
// Text-to-Video uses Q2 model only
|
||||
// 720P: Starts at $0.075, +$0.025/sec
|
||||
// 1080P: Starts at $0.10, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (resolution.includes('1080')) {
|
||||
basePrice = 0.1
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.075
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ReferenceVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const audioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget) {
|
||||
return formatCreditsRangeLabel(0.125, 1.5, {
|
||||
note: '(varies with duration, resolution & audio)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget?.value ?? '').toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
// Check if audio is enabled (adds $0.75)
|
||||
const audioValue = audioWidget?.value
|
||||
const hasAudio =
|
||||
audioValue !== undefined &&
|
||||
audioValue !== null &&
|
||||
String(audioValue).toLowerCase() !== 'false' &&
|
||||
String(audioValue).toLowerCase() !== 'none' &&
|
||||
audioValue !== ''
|
||||
|
||||
// Reference-to-Video uses Q2 model
|
||||
// 720P: Starts at $0.125, +$0.025/sec
|
||||
// 1080P: Starts at $0.375, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (is1080p) {
|
||||
basePrice = 0.375
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.125
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
let cost = basePrice
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
cost = basePrice + pricePerSecond * (duration - 1)
|
||||
}
|
||||
|
||||
// Audio adds $0.75 on top
|
||||
if (hasAudio) {
|
||||
cost += 0.075
|
||||
}
|
||||
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2StartEndToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (!Number.isFinite(duration) || duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsLabel(basePrice)
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2254,8 +2546,13 @@ export const useNodePricing = () => {
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution'],
|
||||
WanReferenceVideoApi: ['duration', 'size'],
|
||||
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
|
||||
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
|
||||
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
59
src/composables/queue/useJobActions.ts
Normal file
59
src/composables/queue/useJobActions.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import type { JobState } from '@/types/queue'
|
||||
|
||||
export type JobAction = {
|
||||
icon: string
|
||||
label: string
|
||||
variant: 'destructive' | 'secondary' | 'textonly'
|
||||
}
|
||||
|
||||
export function useJobActions(
|
||||
job: MaybeRefOrGetter<JobListItem | null | undefined>
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const { cancelJob } = useJobMenu()
|
||||
|
||||
const cancelAction: JobAction = {
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
const cancellableStates: JobState[] = ['pending', 'initialization', 'running']
|
||||
|
||||
const jobRef = computed(() => toValue(job) ?? null)
|
||||
|
||||
const canCancelJob = computed(() => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
currentJob.showClear !== false &&
|
||||
cancellableStates.includes(currentJob.state)
|
||||
)
|
||||
})
|
||||
|
||||
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
|
||||
const currentJob = jobRef.value
|
||||
if (!currentJob) {
|
||||
return
|
||||
}
|
||||
|
||||
await cancelJob(currentJob)
|
||||
})
|
||||
|
||||
return {
|
||||
cancelAction,
|
||||
canCancelJob,
|
||||
runCancelJob
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export type MenuEntry =
|
||||
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
|
||||
*/
|
||||
export function useJobMenu(
|
||||
currentMenuItem: () => JobListItem | null,
|
||||
currentMenuItem: () => JobListItem | null = () => null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -52,37 +52,40 @@ export function useJobMenu(
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const mediaAssetActions = useMediaAssetActions()
|
||||
|
||||
const openJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
|
||||
item ?? currentMenuItem()
|
||||
|
||||
const openJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = target.taskRef?.workflow
|
||||
if (!data) return
|
||||
const filename = `Job ${item.id}.json`
|
||||
const filename = `Job ${target.id}.json`
|
||||
const temp = workflowStore.createTemporary(filename, data)
|
||||
await workflowService.openWorkflow(temp)
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
await copyToClipboard(item.id)
|
||||
const copyJobId = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
await copyToClipboard(target.id)
|
||||
}
|
||||
|
||||
const cancelJob = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
await api.interrupt(item.id)
|
||||
} else if (item.state === 'pending') {
|
||||
await api.deleteItem('queue', item.id)
|
||||
const cancelJob = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
if (target.state === 'running' || target.state === 'initialization') {
|
||||
await api.interrupt(target.id)
|
||||
} else if (target.state === 'pending') {
|
||||
await api.deleteItem('queue', target.id)
|
||||
}
|
||||
await queueStore.update()
|
||||
}
|
||||
|
||||
const copyErrorMessage = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const copyErrorMessage = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const msgs = target.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
@@ -90,10 +93,10 @@ export function useJobMenu(
|
||||
if (message) await copyToClipboard(String(message))
|
||||
}
|
||||
|
||||
const reportError = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const msgs = item.taskRef?.status?.messages as any[] | undefined
|
||||
const reportError = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const msgs = target.taskRef?.status?.messages as any[] | undefined
|
||||
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
|
||||
| ExecutionErrorWsMessage
|
||||
| undefined
|
||||
@@ -102,10 +105,10 @@ export function useJobMenu(
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
const addOutputLoaderNode = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
@@ -153,10 +156,10 @@ export function useJobMenu(
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
@@ -164,14 +167,14 @@ export function useJobMenu(
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = item.taskRef?.workflow
|
||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = target.taskRef?.workflow
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${item.id}.json`
|
||||
let filename = `Job ${target.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
@@ -188,10 +191,10 @@ export function useJobMenu(
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const task = target.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
@@ -202,8 +205,8 @@ export function useJobMenu(
|
||||
}
|
||||
}
|
||||
|
||||
const removeFailedJob = async () => {
|
||||
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||
const removeFailedJob = async (item?: JobListItem | null) => {
|
||||
const task = resolveItem(item)?.taskRef as TaskItemImpl | undefined
|
||||
if (!task) return
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
@@ -234,8 +237,8 @@ export function useJobMenu(
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
const current = resolveItem()
|
||||
if (current) onInspectAsset(current)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
@@ -246,33 +249,33 @@ export function useJobMenu(
|
||||
'Add to current workflow'
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
onClick: addOutputLoaderNode
|
||||
onClick: () => addOutputLoaderNode(resolveItem())
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
onClick: downloadPreviewAsset
|
||||
onClick: () => downloadPreviewAsset(resolveItem())
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: exportJobWorkflow
|
||||
onClick: () => exportJobWorkflow(resolveItem())
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasDeletableAsset
|
||||
@@ -281,7 +284,7 @@ export function useJobMenu(
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: deleteJobAsset
|
||||
onClick: () => deleteJobAsset(resolveItem())
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -293,33 +296,33 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyErrorMessage
|
||||
onClick: () => copyErrorMessage(resolveItem())
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: reportError
|
||||
onClick: () => reportError(resolveItem())
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: removeFailedJob
|
||||
onClick: () => removeFailedJob(resolveItem())
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -328,21 +331,21 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
onClick: () => openJobWorkflow(resolveItem())
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
onClick: () => copyJobId(resolveItem())
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: cancelJob
|
||||
onClick: () => cancelJob(resolveItem())
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -64,6 +64,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
@@ -82,6 +85,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const maskEditorStore = useMaskEditorStore()
|
||||
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
@@ -207,7 +213,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Undo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.undo?.()
|
||||
// If Mask Editor is open, use its history instead of the graph
|
||||
if (dialogStore.isDialogOpen('global-mask-editor')) {
|
||||
maskEditorStore.canvasHistory.undo()
|
||||
} else {
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -216,7 +227,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Redo',
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
await getTracker()?.redo?.()
|
||||
if (dialogStore.isDialogOpen('global-mask-editor')) {
|
||||
maskEditorStore.canvasHistory.redo()
|
||||
} else {
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -905,15 +920,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ToggleManagerProgressDialog',
|
||||
icon: 'pi pi-spinner',
|
||||
label: 'Toggle the Custom Nodes Manager Progress Bar',
|
||||
versionAdded: '1.13.9',
|
||||
function: () => {
|
||||
dialogService.toggleManagerProgressDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.User.OpenSignInDialog',
|
||||
icon: 'pi pi-user',
|
||||
|
||||
100
src/composables/useHelpCenter.ts
Normal file
100
src/composables/useHelpCenter.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
export function useHelpCenter(
|
||||
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible, triggerLocation } =
|
||||
storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
|
||||
// Use conflict acknowledgment state from composable - call only once
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
|
||||
useConflictAcknowledgment()
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle Help Center and track UI button click.
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: `${triggerFrom}_help_center_toggled`
|
||||
})
|
||||
helpCenterStore.toggle(triggerFrom)
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
helpCenterStore.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle What's New popup dismissal
|
||||
* Check if conflict modal should be shown after ComfyUI update
|
||||
*/
|
||||
const handleWhatsNewDismissed = async () => {
|
||||
try {
|
||||
// Check if conflict modal should be shown after update
|
||||
const shouldShow =
|
||||
await conflictDetection.shouldShowConflictModalAfterUpdate()
|
||||
if (shouldShow) {
|
||||
showConflictModal()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Error checking conflict modal:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the node conflict dialog with current conflict data
|
||||
*/
|
||||
const showConflictModal = () => {
|
||||
showNodeConflictDialog({
|
||||
showAfterWhatsNew: true,
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
onMounted(async () => {
|
||||
// Initialize release store to fetch releases for toast and popup
|
||||
await releaseStore.initialize()
|
||||
})
|
||||
|
||||
return {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
shouldShowRedDot,
|
||||
sidebarLocation,
|
||||
toggleHelpCenter,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
}
|
||||
}
|
||||
314
src/composables/usePaste.test.ts
Normal file
314
src/composables/usePaste.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
LGraphCanvas,
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isImageNode } from '@/utils/litegraphUtil'
|
||||
import { pasteImageNode, usePaste } from './usePaste'
|
||||
|
||||
function createMockNode() {
|
||||
return {
|
||||
pos: [0, 0],
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createImageFile(
|
||||
name: string = 'test.png',
|
||||
type: string = 'image/png'
|
||||
): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createAudioFile(
|
||||
name: string = 'test.mp3',
|
||||
type: string = 'audio/mpeg'
|
||||
): File {
|
||||
return new File([''], name, { type })
|
||||
}
|
||||
|
||||
function createDataTransfer(files: File[] = []): DataTransfer {
|
||||
const dataTransfer = new DataTransfer()
|
||||
files.forEach((file) => dataTransfer.items.add(file))
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
const mockCanvas = {
|
||||
current_node: null as LGraphNode | null,
|
||||
graph: {
|
||||
add: vi.fn(),
|
||||
change: vi.fn()
|
||||
} as Partial<LGraph> as LGraph,
|
||||
graph_mouse: [100, 200],
|
||||
pasteFromClipboard: vi.fn(),
|
||||
_deserializeItems: vi.fn()
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||
|
||||
const mockCanvasStore = {
|
||||
canvas: mockCanvas,
|
||||
getCanvas: vi.fn(() => mockCanvas)
|
||||
}
|
||||
|
||||
const mockWorkspaceStore = {
|
||||
shiftDown: false
|
||||
}
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn((target, event, handler) => {
|
||||
target.addEventListener(event, handler)
|
||||
return () => target.removeEventListener(event, handler)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => mockWorkspaceStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
createNode: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isAudioNode: vi.fn(),
|
||||
isImageNode: vi.fn(),
|
||||
isVideoNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/eventHelpers', () => ({
|
||||
shouldIgnoreCopyPaste: vi.fn()
|
||||
}))
|
||||
|
||||
describe('pasteImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should create new LoadImage node when no image node provided', () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
|
||||
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pos).toEqual([100, 200])
|
||||
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.graph!.change).toHaveBeenCalled()
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should use existing image node when provided', () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
|
||||
})
|
||||
|
||||
it('should handle multiple image files', () => {
|
||||
const mockNode = createMockNode()
|
||||
const file1 = createImageFile('test1.png')
|
||||
const file2 = createImageFile('test2.jpg', 'image/jpeg')
|
||||
const dataTransfer = createDataTransfer([file1, file2])
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file1)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
|
||||
})
|
||||
|
||||
it('should do nothing when no image files present', () => {
|
||||
const mockNode = createMockNode()
|
||||
const dataTransfer = createDataTransfer()
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).not.toHaveBeenCalled()
|
||||
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter non-image items', () => {
|
||||
const mockNode = createMockNode()
|
||||
const imageFile = createImageFile()
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||||
const dataTransfer = createDataTransfer([textFile, imageFile])
|
||||
|
||||
pasteImageNode(
|
||||
mockCanvas as unknown as LGraphCanvas,
|
||||
dataTransfer.items,
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile)
|
||||
expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvas.current_node = null
|
||||
mockWorkspaceStore.shiftDown = false
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup) => node as LGraphNode
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle image paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle audio paste', async () => {
|
||||
const mockNode = createMockNode()
|
||||
vi.mocked(LiteGraph.createNode).mockReturnValue(
|
||||
mockNode as unknown as LGraphNode
|
||||
)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadAudio')
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle workflow JSON paste', async () => {
|
||||
const workflow = { version: '1.0', nodes: [], extra: {} }
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', JSON.stringify(workflow))
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore paste when shift is down', () => {
|
||||
mockWorkspaceStore.shiftDown = true
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(LiteGraph.createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use existing image node when selected', () => {
|
||||
const mockNode = {
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
} as unknown as Partial<LGraphNode> as LGraphNode
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isImageNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should call canvas pasteFromClipboard for non-workflow text', () => {
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', 'just some text')
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle clipboard items with metadata', async () => {
|
||||
const data = { test: 'data' }
|
||||
const encoded = btoa(JSON.stringify(data))
|
||||
const html = `<div data-metadata="${encoded}"></div>`
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).toHaveBeenCalledWith(
|
||||
data,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -26,6 +26,51 @@ function pasteClipboardItems(data: DataTransfer): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function pasteItemsOnNode(
|
||||
items: DataTransferItemList,
|
||||
node: LGraphNode | null,
|
||||
contentType: string
|
||||
): void {
|
||||
if (!node) return
|
||||
|
||||
const filteredItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith(contentType)
|
||||
)
|
||||
|
||||
const blob = filteredItems[0]?.getAsFile()
|
||||
if (!blob) return
|
||||
|
||||
node.pasteFile?.(blob)
|
||||
node.pasteFiles?.(
|
||||
Array.from(filteredItems)
|
||||
.map((i) => i.getAsFile())
|
||||
.filter((f) => f !== null)
|
||||
)
|
||||
}
|
||||
|
||||
export function pasteImageNode(
|
||||
canvas: LGraphCanvas,
|
||||
items: DataTransferItemList,
|
||||
imageNode: LGraphNode | null = null
|
||||
): void {
|
||||
const {
|
||||
graph,
|
||||
graph_mouse: [posX, posY]
|
||||
} = canvas
|
||||
|
||||
if (!imageNode) {
|
||||
// No image node selected: add a new one
|
||||
const newNode = LiteGraph.createNode('LoadImage')
|
||||
if (newNode) {
|
||||
newNode.pos = [posX, posY]
|
||||
imageNode = graph?.add(newNode) ?? null
|
||||
}
|
||||
graph?.change()
|
||||
}
|
||||
|
||||
pasteItemsOnNode(items, imageNode, 'image')
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
|
||||
*/
|
||||
@@ -33,28 +78,6 @@ export const usePaste = () => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const pasteItemsOnNode = (
|
||||
items: DataTransferItemList,
|
||||
node: LGraphNode | null,
|
||||
contentType: string
|
||||
) => {
|
||||
if (!node) return
|
||||
|
||||
const filteredItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith(contentType)
|
||||
)
|
||||
|
||||
const blob = filteredItems[0]?.getAsFile()
|
||||
if (!blob) return
|
||||
|
||||
node.pasteFile?.(blob)
|
||||
node.pasteFiles?.(
|
||||
Array.from(filteredItems)
|
||||
.map((i) => i.getAsFile())
|
||||
.filter((f) => f !== null)
|
||||
)
|
||||
}
|
||||
|
||||
useEventListener(document, 'paste', async (e) => {
|
||||
if (shouldIgnoreCopyPaste(e.target)) {
|
||||
// Default system copy
|
||||
@@ -80,8 +103,10 @@ export const usePaste = () => {
|
||||
const isVideoNodeSelected = isNodeSelected && isVideoNode(currentNode)
|
||||
const isAudioNodeSelected = isNodeSelected && isAudioNode(currentNode)
|
||||
|
||||
let imageNode: LGraphNode | null = isImageNodeSelected ? currentNode : null
|
||||
let audioNode: LGraphNode | null = isAudioNodeSelected ? currentNode : null
|
||||
const imageNode: LGraphNode | null = isImageNodeSelected
|
||||
? currentNode
|
||||
: null
|
||||
const videoNode: LGraphNode | null = isVideoNodeSelected
|
||||
? currentNode
|
||||
: null
|
||||
@@ -89,16 +114,7 @@ export const usePaste = () => {
|
||||
// Look for image paste data
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
if (!imageNode) {
|
||||
// No image node selected: add a new one
|
||||
const newNode = LiteGraph.createNode('LoadImage')
|
||||
if (newNode) {
|
||||
newNode.pos = [canvas.graph_mouse[0], canvas.graph_mouse[1]]
|
||||
imageNode = graph?.add(newNode) ?? null
|
||||
}
|
||||
graph?.change()
|
||||
}
|
||||
pasteItemsOnNode(items, imageNode, 'image')
|
||||
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
|
||||
return
|
||||
} else if (item.type.startsWith('video/')) {
|
||||
if (!videoNode) {
|
||||
|
||||
47
src/composables/useProgressBarBackground.ts
Normal file
47
src/composables/useProgressBarBackground.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
type ProgressPercent = number | undefined
|
||||
|
||||
const progressBarContainerClass = 'absolute inset-0'
|
||||
const progressBarBaseClass =
|
||||
'pointer-events-none absolute inset-y-0 left-0 h-full transition-[width]'
|
||||
const progressBarPrimaryClass = `${progressBarBaseClass} bg-interface-panel-job-progress-primary`
|
||||
const progressBarSecondaryClass = `${progressBarBaseClass} bg-interface-panel-job-progress-secondary`
|
||||
|
||||
function clampPercent(value: number) {
|
||||
return Math.min(100, Math.max(0, value))
|
||||
}
|
||||
|
||||
function normalizeProgressPercent(value: ProgressPercent) {
|
||||
if (value === undefined || !Number.isFinite(value)) return undefined
|
||||
|
||||
return clampPercent(value)
|
||||
}
|
||||
|
||||
function hasProgressPercent(value: ProgressPercent) {
|
||||
return normalizeProgressPercent(value) !== undefined
|
||||
}
|
||||
|
||||
function hasAnyProgressPercent(
|
||||
totalPercent: ProgressPercent,
|
||||
currentPercent: ProgressPercent
|
||||
) {
|
||||
return hasProgressPercent(totalPercent) || hasProgressPercent(currentPercent)
|
||||
}
|
||||
|
||||
function progressPercentStyle(value: ProgressPercent) {
|
||||
const normalized = normalizeProgressPercent(value)
|
||||
|
||||
if (normalized === undefined) return undefined
|
||||
|
||||
return { width: `${normalized}%` }
|
||||
}
|
||||
|
||||
export function useProgressBarBackground() {
|
||||
return {
|
||||
progressBarContainerClass,
|
||||
progressBarPrimaryClass,
|
||||
progressBarSecondaryClass,
|
||||
hasProgressPercent,
|
||||
hasAnyProgressPercent,
|
||||
progressPercentStyle
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
@@ -42,6 +43,13 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getFuseOptions: mockGetFuseOptions
|
||||
}
|
||||
}))
|
||||
|
||||
const { useTemplateFiltering } =
|
||||
await import('@/composables/useTemplateFiltering')
|
||||
|
||||
@@ -49,6 +57,7 @@ describe('useTemplateFiltering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockGetFuseOptions.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -272,4 +281,118 @@ describe('useTemplateFiltering', () => {
|
||||
'beta-pro'
|
||||
])
|
||||
})
|
||||
|
||||
describe('loadFuseOptions', () => {
|
||||
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const customFuseOptions: IFuseOptions<TemplateInfo> = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.5 },
|
||||
{ name: 'description', weight: 0.5 }
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true
|
||||
}
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
|
||||
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
await loadFuseOptions()
|
||||
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
expect(filteredTemplates.value).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not update fuseOptions when getFuseOptions returns null', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(null)
|
||||
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const initialResults = filteredTemplates.value
|
||||
|
||||
await loadFuseOptions()
|
||||
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
expect(filteredTemplates.value).toEqual(initialResults)
|
||||
})
|
||||
|
||||
it('handles errors when getFuseOptions fails', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
mockGetFuseOptions.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const initialResults = filteredTemplates.value
|
||||
|
||||
await expect(loadFuseOptions()).rejects.toThrow('Network error')
|
||||
expect(filteredTemplates.value).toEqual(initialResults)
|
||||
})
|
||||
|
||||
it('recreates Fuse instance when fuseOptions change', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'searchable-template',
|
||||
description: 'This is a searchable template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'another-template',
|
||||
description: 'Another template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { loadFuseOptions, searchQuery, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const customFuseOptions = {
|
||||
keys: [{ name: 'name', weight: 1.0 }],
|
||||
threshold: 0.2,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
|
||||
|
||||
await loadFuseOptions()
|
||||
await nextTick()
|
||||
|
||||
searchQuery.value = 'searchable'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.length).toBeGreaterThan(0)
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
@@ -8,6 +9,21 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.3 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'description', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.2 },
|
||||
{ name: 'models', weight: 0.3 }
|
||||
],
|
||||
threshold: 0.33,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
@@ -35,26 +51,14 @@ export function useTemplateFiltering(
|
||||
| 'model-size-low-to-high'
|
||||
>(settingStore.get('Comfy.Templates.SortBy'))
|
||||
|
||||
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
return Array.isArray(templateData) ? templateData : []
|
||||
})
|
||||
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const fuseOptions = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.3 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'description', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.2 },
|
||||
{ name: 'models', weight: 0.3 }
|
||||
],
|
||||
threshold: 0.33,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
|
||||
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions.value))
|
||||
|
||||
const availableModels = computed(() => {
|
||||
const modelSet = new Set<string>()
|
||||
@@ -272,6 +276,13 @@ export function useTemplateFiltering(
|
||||
})
|
||||
}, 500)
|
||||
|
||||
const loadFuseOptions = async () => {
|
||||
const fetchedOptions = await api.getFuseOptions()
|
||||
if (fetchedOptions) {
|
||||
fuseOptions.value = fetchedOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for filter changes and track them
|
||||
watch(
|
||||
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy],
|
||||
@@ -344,6 +355,7 @@ export function useTemplateFiltering(
|
||||
resetFilters,
|
||||
removeModelFilter,
|
||||
removeUseCaseFilter,
|
||||
removeRunsOnFilter
|
||||
removeRunsOnFilter,
|
||||
loadFuseOptions
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -483,23 +483,30 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
|
||||
// Rewrite links
|
||||
for (const l of type.links) {
|
||||
// @ts-expect-error l[0]/l[2] used as node index
|
||||
if (l[0] != null) l[0] = type.nodes[l[0]].index
|
||||
// @ts-expect-error l[0]/l[2] used as node index
|
||||
if (l[2] != null) l[2] = type.nodes[l[2]].index
|
||||
}
|
||||
|
||||
// Rewrite externals
|
||||
if (type.external) {
|
||||
for (const ext of type.external) {
|
||||
ext[0] = type.nodes[ext[0]]
|
||||
if (ext[0] != null) {
|
||||
// @ts-expect-error ext[0] used as node index
|
||||
ext[0] = type.nodes[ext[0]].index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite modifications
|
||||
for (const id of keys) {
|
||||
// @ts-expect-error id used as node index
|
||||
if (config[id]) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
orderedConfig[type.nodes[id].index] = config[id]
|
||||
}
|
||||
// @ts-expect-error id used as config key
|
||||
delete config[id]
|
||||
}
|
||||
|
||||
@@ -529,7 +536,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
if (nodes) recreateNodes.push(...nodes)
|
||||
}
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(types, {})
|
||||
await GroupNodeConfig.registerFromWorkflow(types, [])
|
||||
|
||||
for (const node of recreateNodes) {
|
||||
node.recreate()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { app, ComfyApp } from '@/scripts/app'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
|
||||
function openMaskEditor(node: LGraphNode): void {
|
||||
if (!node) {
|
||||
@@ -109,6 +110,42 @@ app.registerExtension({
|
||||
const store = useMaskEditorStore()
|
||||
store.colorInput?.click()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.MaskEditor.Rotate.Right',
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Rotate Right in MaskEditor',
|
||||
function: async () => {
|
||||
if (!isOpened()) return
|
||||
await useCanvasTransform().rotateClockwise()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.MaskEditor.Rotate.Left',
|
||||
icon: 'pi pi-undo',
|
||||
label: 'Rotate Left in MaskEditor',
|
||||
function: async () => {
|
||||
if (!isOpened()) return
|
||||
await useCanvasTransform().rotateCounterclockwise()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.MaskEditor.Mirror.Horizontal',
|
||||
icon: 'pi pi-arrows-h',
|
||||
label: 'Mirror Horizontal in MaskEditor',
|
||||
function: async () => {
|
||||
if (!isOpened()) return
|
||||
await useCanvasTransform().mirrorHorizontal()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.MaskEditor.Mirror.Vertical',
|
||||
icon: 'pi pi-arrows-v',
|
||||
label: 'Mirror Vertical in MaskEditor',
|
||||
function: async () => {
|
||||
if (!isOpened()) return
|
||||
await useCanvasTransform().mirrorVertical()
|
||||
}
|
||||
}
|
||||
],
|
||||
init() {
|
||||
|
||||
@@ -370,9 +370,10 @@ const ext: ComfyExtension = {
|
||||
const node = app.canvas.graph?.getNodeById(nodeIds[i])
|
||||
const nodeData = node?.constructor.nodeData
|
||||
|
||||
let groupData = GroupNodeHandler.getGroupData(node)
|
||||
if (groupData) {
|
||||
groupData = groupData.nodeData
|
||||
if (!node) continue
|
||||
const groupConfig = GroupNodeHandler.getGroupData(node)
|
||||
if (groupConfig) {
|
||||
const groupData = groupConfig.nodeData
|
||||
// @ts-expect-error
|
||||
if (!data.groupNodes) {
|
||||
// @ts-expect-error
|
||||
@@ -402,7 +403,10 @@ const ext: ComfyExtension = {
|
||||
callback: () => {
|
||||
clipboardAction(async () => {
|
||||
const data = JSON.parse(t.data)
|
||||
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {})
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
data.groupNodes ?? {},
|
||||
[]
|
||||
)
|
||||
|
||||
// Check for old clipboard format
|
||||
if (!data.reroutes) {
|
||||
|
||||
@@ -41,7 +41,7 @@ useExtensionService().registerExtension({
|
||||
app
|
||||
)
|
||||
|
||||
showAsPlaintextWidget.widget.callback = (value) => {
|
||||
showAsPlaintextWidget.widget.callback = (value: boolean) => {
|
||||
showValueWidget.hidden = !value
|
||||
showValueWidget.options.hidden = !value
|
||||
showValueWidgetPlain.hidden = value
|
||||
|
||||
@@ -30,6 +30,7 @@ const localeLoaders: Record<
|
||||
> = {
|
||||
ar: () => import('./locales/ar/main.json'),
|
||||
es: () => import('./locales/es/main.json'),
|
||||
fa: () => import('./locales/fa/main.json'),
|
||||
fr: () => import('./locales/fr/main.json'),
|
||||
ja: () => import('./locales/ja/main.json'),
|
||||
ko: () => import('./locales/ko/main.json'),
|
||||
@@ -46,6 +47,7 @@ const nodeDefsLoaders: Record<
|
||||
> = {
|
||||
ar: () => import('./locales/ar/nodeDefs.json'),
|
||||
es: () => import('./locales/es/nodeDefs.json'),
|
||||
fa: () => import('./locales/fa/nodeDefs.json'),
|
||||
fr: () => import('./locales/fr/nodeDefs.json'),
|
||||
ja: () => import('./locales/ja/nodeDefs.json'),
|
||||
ko: () => import('./locales/ko/nodeDefs.json'),
|
||||
@@ -62,6 +64,7 @@ const commandsLoaders: Record<
|
||||
> = {
|
||||
ar: () => import('./locales/ar/commands.json'),
|
||||
es: () => import('./locales/es/commands.json'),
|
||||
fa: () => import('./locales/fa/commands.json'),
|
||||
fr: () => import('./locales/fr/commands.json'),
|
||||
ja: () => import('./locales/ja/commands.json'),
|
||||
ko: () => import('./locales/ko/commands.json'),
|
||||
@@ -78,6 +81,7 @@ const settingsLoaders: Record<
|
||||
> = {
|
||||
ar: () => import('./locales/ar/settings.json'),
|
||||
es: () => import('./locales/es/settings.json'),
|
||||
fa: () => import('./locales/fa/settings.json'),
|
||||
fr: () => import('./locales/fr/settings.json'),
|
||||
ja: () => import('./locales/ja/settings.json'),
|
||||
ko: () => import('./locales/ko/settings.json'),
|
||||
|
||||
@@ -15,7 +15,7 @@ import { LGraphGroup } from './LGraphGroup'
|
||||
import { LGraphNode } from './LGraphNode'
|
||||
import type { NodeId } from './LGraphNode'
|
||||
import { LLink } from './LLink'
|
||||
import type { LinkId } from './LLink'
|
||||
import type { LinkId, SerialisedLLinkArray } from './LLink'
|
||||
import { MapProxyHandler } from './MapProxyHandler'
|
||||
import { Reroute } from './Reroute'
|
||||
import type { RerouteId } from './Reroute'
|
||||
@@ -102,11 +102,24 @@ export interface LGraphConfig {
|
||||
links_ontop?: any
|
||||
}
|
||||
|
||||
export interface GroupNodeWorkflowData {
|
||||
external: (number | string)[][]
|
||||
links: SerialisedLLinkArray[]
|
||||
nodes: {
|
||||
index?: number
|
||||
type?: string
|
||||
inputs?: unknown[]
|
||||
outputs?: unknown[]
|
||||
}[]
|
||||
config?: Record<number, unknown>
|
||||
}
|
||||
|
||||
export interface LGraphExtra extends Dictionary<unknown> {
|
||||
reroutes?: SerialisableReroute[]
|
||||
linkExtensions?: { id: number; parentId: number | undefined }[]
|
||||
ds?: DragAndScaleState
|
||||
workflowRendererVersion?: RendererType
|
||||
groupNodes?: Record<string, GroupNodeWorkflowData>
|
||||
}
|
||||
|
||||
export interface BaseLGraph {
|
||||
@@ -1509,6 +1522,22 @@ export class LGraph
|
||||
} {
|
||||
if (items.size === 0)
|
||||
throw new Error('Cannot convert to subgraph: nothing to convert')
|
||||
|
||||
// Record state before conversion for proper undo support
|
||||
this.beforeChange()
|
||||
|
||||
try {
|
||||
return this._convertToSubgraphImpl(items)
|
||||
} finally {
|
||||
// Mark state change complete for proper undo support
|
||||
this.afterChange()
|
||||
}
|
||||
}
|
||||
|
||||
private _convertToSubgraphImpl(items: Set<Positionable>): {
|
||||
subgraph: Subgraph
|
||||
node: SubgraphNode
|
||||
} {
|
||||
const { state, revision, config } = this
|
||||
const firstChild = [...items][0]
|
||||
if (items.size === 1 && firstChild instanceof LGraphGroup) {
|
||||
@@ -1715,6 +1744,7 @@ export class LGraph
|
||||
|
||||
subgraphNode._setConcreteSlots()
|
||||
subgraphNode.arrange()
|
||||
|
||||
this.canvasAction((c) =>
|
||||
c.canvas.dispatchEvent(
|
||||
new CustomEvent('subgraph-converted', {
|
||||
@@ -1733,9 +1763,23 @@ export class LGraph
|
||||
if (!(subgraphNode instanceof SubgraphNode))
|
||||
throw new Error('Can only unpack Subgraph Nodes')
|
||||
|
||||
// Record state before unpacking for proper undo support
|
||||
this.beforeChange()
|
||||
|
||||
try {
|
||||
this._unpackSubgraphImpl(subgraphNode, options)
|
||||
} finally {
|
||||
// Mark state change complete for proper undo support
|
||||
this.afterChange()
|
||||
}
|
||||
}
|
||||
|
||||
private _unpackSubgraphImpl(
|
||||
subgraphNode: SubgraphNode,
|
||||
options?: { skipMissingNodes?: boolean }
|
||||
) {
|
||||
const skipMissingNodes = options?.skipMissingNodes ?? false
|
||||
|
||||
this.beforeChange()
|
||||
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
|
||||
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
|
||||
const positionables = [
|
||||
@@ -2006,7 +2050,6 @@ export class LGraph
|
||||
}
|
||||
|
||||
this.canvasAction((c) => c.selectItems(toSelect))
|
||||
this.afterChange()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LGraphNode } from './LGraphNode'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Default properties to track
|
||||
*/
|
||||
@@ -11,7 +15,6 @@ const DEFAULT_TRACKED_PROPERTIES: string[] = [
|
||||
'color',
|
||||
'bgcolor'
|
||||
]
|
||||
|
||||
/**
|
||||
* Manages node properties with optional change tracking and instrumentation.
|
||||
*/
|
||||
@@ -37,6 +40,34 @@ export class LGraphNodeProperties {
|
||||
}
|
||||
}
|
||||
|
||||
#resolveTargetObject(parts: string[]): {
|
||||
targetObject: Record<string, unknown>
|
||||
propertyName: string
|
||||
} {
|
||||
// LGraphNode supports dynamic property access at runtime
|
||||
let targetObject: Record<string, unknown> = this.node as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { targetObject, propertyName: parts[0] }
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]
|
||||
const next = targetObject[key]
|
||||
if (isRecord(next)) {
|
||||
targetObject = next
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetObject,
|
||||
propertyName: parts[parts.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruments a single property to track changes
|
||||
*/
|
||||
@@ -47,15 +78,7 @@ export class LGraphNodeProperties {
|
||||
this.#ensureNestedPath(path)
|
||||
}
|
||||
|
||||
let targetObject: any = this.node
|
||||
let propertyName = parts[0]
|
||||
|
||||
if (parts.length > 1) {
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
targetObject = targetObject[parts[i]]
|
||||
}
|
||||
propertyName = parts.at(-1)!
|
||||
}
|
||||
const { targetObject, propertyName } = this.#resolveTargetObject(parts)
|
||||
|
||||
const hasProperty = Object.prototype.hasOwnProperty.call(
|
||||
targetObject,
|
||||
@@ -64,11 +87,11 @@ export class LGraphNodeProperties {
|
||||
const currentValue = targetObject[propertyName]
|
||||
|
||||
if (!hasProperty) {
|
||||
let value: any = undefined
|
||||
let value: unknown = undefined
|
||||
|
||||
Object.defineProperty(targetObject, propertyName, {
|
||||
get: () => value,
|
||||
set: (newValue: any) => {
|
||||
set: (newValue: unknown) => {
|
||||
const oldValue = value
|
||||
value = newValue
|
||||
this.#emitPropertyChange(path, oldValue, newValue)
|
||||
@@ -108,13 +131,20 @@ export class LGraphNodeProperties {
|
||||
*/
|
||||
#createInstrumentedDescriptor(
|
||||
propertyPath: string,
|
||||
initialValue: any
|
||||
initialValue: unknown
|
||||
): PropertyDescriptor {
|
||||
let value = initialValue
|
||||
return this.#createInstrumentedDescriptorTyped(propertyPath, initialValue)
|
||||
}
|
||||
|
||||
#createInstrumentedDescriptorTyped<TValue>(
|
||||
propertyPath: string,
|
||||
initialValue: TValue
|
||||
): PropertyDescriptor {
|
||||
let value: TValue = initialValue
|
||||
|
||||
return {
|
||||
get: () => value,
|
||||
set: (newValue: any) => {
|
||||
set: (newValue: TValue) => {
|
||||
const oldValue = value
|
||||
value = newValue
|
||||
this.#emitPropertyChange(propertyPath, oldValue, newValue)
|
||||
@@ -129,8 +159,16 @@ export class LGraphNodeProperties {
|
||||
*/
|
||||
#emitPropertyChange(
|
||||
propertyPath: string,
|
||||
oldValue: any,
|
||||
newValue: any
|
||||
oldValue: unknown,
|
||||
newValue: unknown
|
||||
): void {
|
||||
this.#emitPropertyChangeTyped(propertyPath, oldValue, newValue)
|
||||
}
|
||||
|
||||
#emitPropertyChangeTyped<TValue>(
|
||||
propertyPath: string,
|
||||
oldValue: TValue,
|
||||
newValue: TValue
|
||||
): void {
|
||||
this.node.graph?.trigger('node:property:changed', {
|
||||
nodeId: this.node.id,
|
||||
@@ -145,7 +183,11 @@ export class LGraphNodeProperties {
|
||||
*/
|
||||
#ensureNestedPath(path: string): void {
|
||||
const parts = path.split('.')
|
||||
let current: any = this.node
|
||||
// LGraphNode supports dynamic property access at runtime
|
||||
let current: Record<string, unknown> = this.node as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
// Create all parent objects except the last property
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
@@ -153,7 +195,10 @@ export class LGraphNodeProperties {
|
||||
if (!current[part]) {
|
||||
current[part] = {}
|
||||
}
|
||||
current = current[part]
|
||||
const next = current[part]
|
||||
if (isRecord(next)) {
|
||||
current = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +220,7 @@ export class LGraphNodeProperties {
|
||||
* Custom toJSON method for JSON.stringify
|
||||
* Returns undefined to exclude from serialization since we only use defaults
|
||||
*/
|
||||
toJSON(): any {
|
||||
toJSON(): undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export class LiteGraphGlobal {
|
||||
DEFAULT_SHADOW_COLOR = 'rgba(0,0,0,0.5)'
|
||||
|
||||
DEFAULT_GROUP_FONT = 24
|
||||
DEFAULT_GROUP_FONT_SIZE?: any
|
||||
DEFAULT_GROUP_FONT_SIZE = 24
|
||||
GROUP_FONT = 'Inter'
|
||||
|
||||
WIDGET_BGCOLOR = '#222'
|
||||
@@ -716,7 +716,7 @@ export class LiteGraphGlobal {
|
||||
}
|
||||
|
||||
// used to create nodes from wrapping functions
|
||||
getParameterNames(func: (...args: any) => any): string[] {
|
||||
getParameterNames(func: (...args: unknown[]) => unknown): string[] {
|
||||
return String(func)
|
||||
.replaceAll(/\/\/.*$/gm, '') // strip single-line comments
|
||||
.replaceAll(/\s+/g, '') // strip white space
|
||||
@@ -971,7 +971,10 @@ export class LiteGraphGlobal {
|
||||
}
|
||||
}
|
||||
|
||||
extendClass(target: any, origin: any): void {
|
||||
extendClass(
|
||||
target: Record<string, unknown> & { prototype?: object },
|
||||
origin: Record<string, unknown> & { prototype?: object }
|
||||
): void {
|
||||
for (const i in origin) {
|
||||
// copy class properties
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
@@ -979,33 +982,24 @@ export class LiteGraphGlobal {
|
||||
target[i] = origin[i]
|
||||
}
|
||||
|
||||
if (origin.prototype) {
|
||||
if (origin.prototype && target.prototype) {
|
||||
const originProto = origin.prototype as Record<string, unknown>
|
||||
const targetProto = target.prototype as Record<string, unknown>
|
||||
|
||||
// copy prototype properties
|
||||
for (const i in origin.prototype) {
|
||||
for (const i in originProto) {
|
||||
// only enumerable
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (!origin.prototype.hasOwnProperty(i)) continue
|
||||
if (!originProto.hasOwnProperty(i)) continue
|
||||
|
||||
// avoid overwriting existing ones
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (target.prototype.hasOwnProperty(i)) continue
|
||||
if (targetProto.hasOwnProperty(i)) continue
|
||||
|
||||
// copy getters
|
||||
if (origin.prototype.__lookupGetter__(i)) {
|
||||
target.prototype.__defineGetter__(
|
||||
i,
|
||||
origin.prototype.__lookupGetter__(i)
|
||||
)
|
||||
} else {
|
||||
target.prototype[i] = origin.prototype[i]
|
||||
}
|
||||
|
||||
// and setters
|
||||
if (origin.prototype.__lookupSetter__(i)) {
|
||||
target.prototype.__defineSetter__(
|
||||
i,
|
||||
origin.prototype.__lookupSetter__(i)
|
||||
)
|
||||
// Use Object.getOwnPropertyDescriptor to copy getters/setters properly
|
||||
const descriptor = Object.getOwnPropertyDescriptor(originProto, i)
|
||||
if (descriptor) {
|
||||
Object.defineProperty(targetProto, i, descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class MapProxyHandler<V> implements ProxyHandler<
|
||||
return [...target.keys()].map(String)
|
||||
}
|
||||
|
||||
get(target: Map<number | string, V>, p: string | symbol): any {
|
||||
get(target: Map<number | string, V>, p: string | symbol): V | undefined {
|
||||
// Workaround does not support link IDs of "values", "entries", "constructor", etc.
|
||||
if (p in target) return Reflect.get(target, p, target)
|
||||
if (typeof p === 'symbol') return
|
||||
@@ -42,7 +42,7 @@ export class MapProxyHandler<V> implements ProxyHandler<
|
||||
set(
|
||||
target: Map<number | string, V>,
|
||||
p: string | symbol,
|
||||
newValue: any
|
||||
newValue: V
|
||||
): boolean {
|
||||
if (typeof p === 'symbol') return false
|
||||
|
||||
@@ -55,7 +55,7 @@ export class MapProxyHandler<V> implements ProxyHandler<
|
||||
return target.delete(p as number | string)
|
||||
}
|
||||
|
||||
static bindAllMethods(map: Map<any, any>): void {
|
||||
static bindAllMethods(map: Map<unknown, unknown>): void {
|
||||
map.clear = map.clear.bind(map)
|
||||
map.delete = map.delete.bind(map)
|
||||
map.forEach = map.forEach.bind(map)
|
||||
|
||||
@@ -22,7 +22,7 @@ LiteGraphGlobal {
|
||||
"CurveEditor": [Function],
|
||||
"DEFAULT_FONT": "Inter",
|
||||
"DEFAULT_GROUP_FONT": 24,
|
||||
"DEFAULT_GROUP_FONT_SIZE": undefined,
|
||||
"DEFAULT_GROUP_FONT_SIZE": 24,
|
||||
"DEFAULT_POSITION": [
|
||||
100,
|
||||
100,
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
|
||||
size?: Size
|
||||
min_height?: number
|
||||
slot_start_y?: number
|
||||
widgets_info?: any
|
||||
widgets_info?: Record<string, unknown>
|
||||
collapsable?: boolean
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
|
||||
@@ -343,7 +343,7 @@ export interface IBaseWidget<
|
||||
|
||||
// TODO: Confirm this format
|
||||
callback?(
|
||||
value: any,
|
||||
value: unknown,
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
|
||||
@@ -78,7 +78,7 @@ export abstract class BaseWidget<
|
||||
tooltip?: string
|
||||
element?: HTMLElement
|
||||
callback?(
|
||||
value: any,
|
||||
value: TWidget['value'],
|
||||
canvas?: LGraphCanvas,
|
||||
node?: LGraphNode,
|
||||
pos?: Point,
|
||||
|
||||
@@ -65,7 +65,7 @@ export class ButtonWidget
|
||||
this.clicked = true
|
||||
canvas.setDirty(true)
|
||||
|
||||
// Call the callback with widget instance and other context
|
||||
this.callback?.(this, canvas, node, pos, e)
|
||||
// Call the callback with widget value
|
||||
this.callback?.(this.value, canvas, node, pos, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
{
|
||||
"Comfy-Desktop_CheckForUpdates": {
|
||||
"label": "التحقق من التحديثات"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
|
||||
"label": "فتح مجلد العقد المخصصة"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenInputsFolder": {
|
||||
"label": "فتح مجلد المدخلات"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenLogsFolder": {
|
||||
"label": "فتح مجلد السجلات"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelConfig": {
|
||||
"label": "فتح extra_model_paths.yaml"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelsFolder": {
|
||||
"label": "فتح مجلد النماذج"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenOutputsFolder": {
|
||||
"label": "فتح مجلد المخرجات"
|
||||
},
|
||||
"Comfy-Desktop_OpenDevTools": {
|
||||
"label": "فتح أدوات المطور"
|
||||
},
|
||||
"Comfy-Desktop_OpenUserGuide": {
|
||||
"label": "دليل مستخدم سطح المكتب"
|
||||
},
|
||||
"Comfy-Desktop_Quit": {
|
||||
"label": "خروج"
|
||||
},
|
||||
"Comfy-Desktop_Reinstall": {
|
||||
"label": "إعادة التثبيت"
|
||||
},
|
||||
"Comfy-Desktop_Restart": {
|
||||
"label": "إعادة التشغيل"
|
||||
},
|
||||
"Comfy_3DViewer_Open3DViewer": {
|
||||
"label": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة"
|
||||
},
|
||||
@@ -155,18 +191,30 @@
|
||||
"Comfy_Manager_ShowUpdateAvailablePacks": {
|
||||
"label": "التحقق من تحديثات العقد المخصصة"
|
||||
},
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "تبديل شريط تقدم مدير العقد المخصصة"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Decrease": {
|
||||
"label": "تقليل حجم الفرشاة في محرر القناع"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Increase": {
|
||||
"label": "زيادة حجم الفرشاة في محرر القناع"
|
||||
},
|
||||
"Comfy_MaskEditor_ColorPicker": {
|
||||
"label": "فتح منتقي الألوان في محرر القناع"
|
||||
},
|
||||
"Comfy_MaskEditor_Mirror_Horizontal": {
|
||||
"label": "انعكاس أفقي في محرر القناع"
|
||||
},
|
||||
"Comfy_MaskEditor_Mirror_Vertical": {
|
||||
"label": "انعكاس عمودي في محرر القناع"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "فتح محرر القناع للعقدة المحددة"
|
||||
},
|
||||
"Comfy_MaskEditor_Rotate_Left": {
|
||||
"label": "تدوير لليسار في محرر القناع"
|
||||
},
|
||||
"Comfy_MaskEditor_Rotate_Right": {
|
||||
"label": "تدوير لليمين في محرر القناع"
|
||||
},
|
||||
"Comfy_Memory_UnloadModels": {
|
||||
"label": "تفريغ النماذج"
|
||||
},
|
||||
@@ -197,6 +245,9 @@
|
||||
"Comfy_QueueSelectedOutputNodes": {
|
||||
"label": "إدراج عقد الإخراج المحددة في قائمة الانتظار"
|
||||
},
|
||||
"Comfy_Queue_ToggleOverlay": {
|
||||
"label": "تبديل سجل المهام"
|
||||
},
|
||||
"Comfy_Redo": {
|
||||
"label": "إعادة"
|
||||
},
|
||||
@@ -221,6 +272,12 @@
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "مركز المساعدة"
|
||||
},
|
||||
"Comfy_ToggleLinear": {
|
||||
"label": "تبديل الوضع الخطي"
|
||||
},
|
||||
"Comfy_ToggleQPOV2": {
|
||||
"label": "تبديل لوحة قائمة الانتظار V2"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "تبديل النمط (فاتح/داكن)"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,17 @@
|
||||
"Comfy_EnableWorkflowViewRestore": {
|
||||
"name": "حفظ واستعادة موقع اللوحة ومستوى التكبير في سير العمل"
|
||||
},
|
||||
"Comfy_Execution_PreviewMethod": {
|
||||
"name": "طريقة المعاينة الحية",
|
||||
"options": {
|
||||
"auto": "تلقائي",
|
||||
"default": "default",
|
||||
"latent2rgb": "latent2rgb",
|
||||
"none": "بدون",
|
||||
"taesd": "taesd"
|
||||
},
|
||||
"tooltip": "طريقة المعاينة الحية أثناء توليد الصورة. \"default\" تستخدم إعداد الخادم CLI."
|
||||
},
|
||||
"Comfy_FloatRoundingPrecision": {
|
||||
"name": "عدد أرقام التقريب العشرية لأدوات التحكم العائمة [0 = تلقائي]",
|
||||
"tooltip": "(يتطلب إعادة تحميل الصفحة)"
|
||||
@@ -100,6 +111,10 @@
|
||||
"None": "لا شيء"
|
||||
}
|
||||
},
|
||||
"Comfy_Graph_LiveSelection": {
|
||||
"name": "تحديد مباشر",
|
||||
"tooltip": "عند التفعيل، يتم تحديد/إلغاء تحديد العقد في الوقت الفعلي أثناء سحب مستطيل التحديد، كما في أدوات التصميم الأخرى."
|
||||
},
|
||||
"Comfy_Graph_ZoomSpeed": {
|
||||
"name": "سرعة تكبير اللوحة"
|
||||
},
|
||||
@@ -166,6 +181,15 @@
|
||||
"name": "أدنى شدة إضاءة",
|
||||
"tooltip": "يحدد الحد الأدنى المسموح به لشدة الإضاءة في المشاهد ثلاثية الأبعاد."
|
||||
},
|
||||
"Comfy_Load3D_PLYEngine": {
|
||||
"name": "محرك PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "اختر المحرك لتحميل ملفات PLY. \"threejs\" يستخدم PLYLoader الأصلي من Three.js (الأفضل لملفات PLY الشبكية). \"fastply\" يستخدم محمل محسن لملفات PLY السحابية النقطية بنسق ASCII. \"sparkjs\" يستخدم Spark.js لملفات PLY الخاصة بتوزيع Gaussian ثلاثي الأبعاد."
|
||||
},
|
||||
"Comfy_Load3D_ShowGrid": {
|
||||
"name": "رؤية الشبكة الابتدائية",
|
||||
"tooltip": "يتحكم في ظهور الشبكة بشكل افتراضي عند إنشاء عنصر ثلاثي الأبعاد جديد."
|
||||
|
||||
@@ -191,9 +191,6 @@
|
||||
"Comfy_Manager_ShowUpdateAvailablePacks": {
|
||||
"label": "Check for Custom Node Updates"
|
||||
},
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "Toggle the Custom Nodes Manager Progress Bar"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Decrease": {
|
||||
"label": "Decrease Brush Size in MaskEditor"
|
||||
},
|
||||
@@ -203,9 +200,21 @@
|
||||
"Comfy_MaskEditor_ColorPicker": {
|
||||
"label": "Open Color Picker in MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_Mirror_Horizontal": {
|
||||
"label": "Mirror Horizontal in MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_Mirror_Vertical": {
|
||||
"label": "Mirror Vertical in MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "Open Mask Editor for Selected Node"
|
||||
},
|
||||
"Comfy_MaskEditor_Rotate_Left": {
|
||||
"label": "Rotate Left in MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_Rotate_Right": {
|
||||
"label": "Rotate Right in MaskEditor"
|
||||
},
|
||||
"Comfy_Memory_UnloadModels": {
|
||||
"label": "Unload Models"
|
||||
},
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
"downloadVideo": "Download video",
|
||||
"editOrMaskImage": "Edit or mask image",
|
||||
"editImage": "Edit image",
|
||||
"decrement": "Decrement",
|
||||
"deleteImage": "Delete image",
|
||||
"deleteAudioFile": "Delete audio file",
|
||||
"increment": "Increment",
|
||||
"removeImage": "Remove image",
|
||||
"removeVideo": "Remove video",
|
||||
"chart": "Chart",
|
||||
@@ -694,6 +696,7 @@
|
||||
"noFilesFound": "No files found",
|
||||
"noImportedFiles": "No imported files found",
|
||||
"noGeneratedFiles": "No generated files found",
|
||||
"generatedAssetsHeader": "Generated assets",
|
||||
"noFilesFoundMessage": "Upload files or generate content to see them here",
|
||||
"browseTemplates": "Browse example templates",
|
||||
"openWorkflow": "Open workflow in local file system",
|
||||
@@ -835,6 +838,7 @@
|
||||
"customNodesManager": "Custom Nodes Manager",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"helpAndFeedback": "Help & Feedback",
|
||||
"queue": "Queue Panel"
|
||||
},
|
||||
"tabMenu": {
|
||||
@@ -974,6 +978,10 @@
|
||||
"clear": "Clear",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"rotateLeft": "Rotate Left",
|
||||
"rotateRight": "Rotate Right",
|
||||
"mirrorHorizontal": "Mirror Horizontal",
|
||||
"mirrorVertical": "Mirror Vertical",
|
||||
"clickToResetZoom": "Click to reset zoom",
|
||||
"brushSettings": "Brush Settings",
|
||||
"brushShape": "Brush Shape",
|
||||
@@ -1147,11 +1155,14 @@
|
||||
"Manager Menu (Legacy)": "Manager Menu (Legacy)",
|
||||
"Install Missing Custom Nodes": "Install Missing Custom Nodes",
|
||||
"Check for Custom Node Updates": "Check for Custom Node Updates",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
|
||||
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
|
||||
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
|
||||
"Open Color Picker in MaskEditor": "Open Color Picker in MaskEditor",
|
||||
"Mirror Horizontal in MaskEditor": "Mirror Horizontal in MaskEditor",
|
||||
"Mirror Vertical in MaskEditor": "Mirror Vertical in MaskEditor",
|
||||
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
|
||||
"Rotate Left in MaskEditor": "Rotate Left in MaskEditor",
|
||||
"Rotate Right in MaskEditor": "Rotate Right in MaskEditor",
|
||||
"Unload Models": "Unload Models",
|
||||
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
|
||||
"New": "New",
|
||||
@@ -1515,6 +1526,7 @@
|
||||
"HOOK_KEYFRAMES": "HOOK_KEYFRAMES",
|
||||
"HOOKS": "HOOKS",
|
||||
"IMAGE": "IMAGE",
|
||||
"IMAGECOMPARE": "IMAGECOMPARE",
|
||||
"INT": "INT",
|
||||
"LATENT": "LATENT",
|
||||
"LATENT_OPERATION": "LATENT_OPERATION",
|
||||
@@ -1918,12 +1930,27 @@
|
||||
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
||||
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
||||
"howManyCredits": "How many credits would you like to add?",
|
||||
"videosEstimate": "~{count} videos",
|
||||
"usdAmount": "${amount}",
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"templateNote": "*Generated with Wan Fun Control template",
|
||||
"buy": "Buy",
|
||||
"purchaseError": "Purchase Failed",
|
||||
"purchaseErrorDetail": "Failed to purchase credits: {error}",
|
||||
"unknownError": "An unknown error occurred"
|
||||
"unknownError": "An unknown error occurred",
|
||||
"viewPricing": "View pricing details",
|
||||
"youPay": "Amount (USD)",
|
||||
"youGet": "Credits",
|
||||
"buyCredits": "Continue to payment",
|
||||
"minimumPurchase": "${amount} minimum ({credits} credits)",
|
||||
"maximumAmount": "${amount} max.",
|
||||
"minRequired": "{credits} credits minimum",
|
||||
"maxAllowed": "{credits} credits maximum.",
|
||||
"creditsPerDollar": "credits per dollar",
|
||||
"amountToPayLabel": "Amount to pay in dollars",
|
||||
"creditsToReceiveLabel": "Credits to receive",
|
||||
"selectAmount": "Select amount",
|
||||
"needMore": "Need more?",
|
||||
"contactUs": "Contact us"
|
||||
},
|
||||
"creditsAvailable": "Credits available",
|
||||
"refreshes": "Refreshes {date}",
|
||||
@@ -1960,9 +1987,9 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||
"creditsRemainingThisMonth": "Credits remaining this month",
|
||||
"creditsRemainingThisYear": "Credits remaining this year",
|
||||
"creditsYouveAdded": "Credits you've added",
|
||||
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||
"creditsYouveAdded": "Additional",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
@@ -2017,7 +2044,7 @@
|
||||
"subscribeTo": "Subscribe to {plan}",
|
||||
"monthlyCreditsLabel": "Monthly credits",
|
||||
"yearlyCreditsLabel": "Total yearly credits",
|
||||
"maxDurationLabel": "Max duration of each workflow run",
|
||||
"maxDurationLabel": "Max run duration",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
@@ -2395,9 +2422,12 @@
|
||||
},
|
||||
"selection": {
|
||||
"selectedCount": "Assets Selected: {count}",
|
||||
"multipleSelectedAssets": "Multiple assets selected",
|
||||
"deselectAll": "Deselect all",
|
||||
"downloadSelected": "Download",
|
||||
"downloadSelectedAll": "Download all",
|
||||
"deleteSelected": "Delete",
|
||||
"deleteSelectedAll": "Delete all",
|
||||
"downloadStarted": "Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file(s)",
|
||||
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
|
||||
@@ -2480,5 +2510,21 @@
|
||||
"help": {
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
},
|
||||
"progressToast": {
|
||||
"importingModels": "Importing Models",
|
||||
"downloadingModel": "Downloading model...",
|
||||
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
|
||||
"allDownloadsCompleted": "All downloads completed",
|
||||
"noImportsInQueue": "No {filter} in queue",
|
||||
"failed": "Failed",
|
||||
"finished": "Finished",
|
||||
"pending": "Pending",
|
||||
"progressCount": "{completed} of {total}",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3876,6 +3876,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageCompare": {
|
||||
"display_name": "Image Compare",
|
||||
"description": "Compares two images side by side with a slider.",
|
||||
"inputs": {
|
||||
"compare_view": {
|
||||
"name": "compare_view"
|
||||
},
|
||||
"image_a": {
|
||||
"name": "image_a"
|
||||
},
|
||||
"image_b": {
|
||||
"name": "image_b"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageCompositeMasked": {
|
||||
"display_name": "ImageCompositeMasked",
|
||||
"inputs": {
|
||||
@@ -4383,6 +4398,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"JoinAudioChannels": {
|
||||
"display_name": "Join Audio Channels",
|
||||
"description": "Joins left and right mono audio channels into a stereo audio.",
|
||||
"inputs": {
|
||||
"audio_left": {
|
||||
"name": "audio_left"
|
||||
},
|
||||
"audio_right": {
|
||||
"name": "audio_right"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"JoinImageWithAlpha": {
|
||||
"display_name": "Join Image with Alpha",
|
||||
"inputs": {
|
||||
@@ -6088,6 +6121,9 @@
|
||||
},
|
||||
"ckpt_name": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"device": {
|
||||
"name": "device"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -14318,6 +14354,166 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu2ImageToVideoNode": {
|
||||
"display_name": "Vidu2 Image-to-Video Generation",
|
||||
"description": "Generate a video from an image and an optional prompt.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "An image to be used as the start frame of the generated video."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "An optional text prompt for video generation (max 2000 characters)."
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"movement_amplitude": {
|
||||
"name": "movement_amplitude",
|
||||
"tooltip": "The movement amplitude of objects in the frame."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu2ReferenceVideoNode": {
|
||||
"display_name": "Vidu2 Reference-to-Video Generation",
|
||||
"description": "Generate a video from multiple reference images and a prompt.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"subjects": {
|
||||
"name": "subjects",
|
||||
"tooltip": "For each subject, provide up to 3 reference images (7 images total across all subjects). Reference them in prompts via @subject{subject_id}."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "When enabled, the video will include generated speech and background music based on the prompt."
|
||||
},
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "When enabled video will contain generated speech and background music based on the prompt."
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"movement_amplitude": {
|
||||
"name": "movement_amplitude",
|
||||
"tooltip": "The movement amplitude of objects in the frame."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu2StartEndToVideoNode": {
|
||||
"display_name": "Vidu2 Start/End Frame-to-Video Generation",
|
||||
"description": "Generate a video from a start frame, an end frame, and a prompt.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"first_frame": {
|
||||
"name": "first_frame"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "end_frame"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt description (max 2000 characters)."
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"movement_amplitude": {
|
||||
"name": "movement_amplitude",
|
||||
"tooltip": "The movement amplitude of objects in the frame."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vidu2TextToVideoNode": {
|
||||
"display_name": "Vidu2 Text-to-Video Generation",
|
||||
"description": "Generate video from a text prompt",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "A textual description for video generation, with a maximum length of 2000 characters."
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"background_music": {
|
||||
"name": "background_music",
|
||||
"tooltip": "Whether to add background music to the generated video."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ViduImageToVideoNode": {
|
||||
"display_name": "Vidu Image To Video Generation",
|
||||
"description": "Generate video from image and optional prompt",
|
||||
@@ -14362,7 +14558,7 @@
|
||||
},
|
||||
"ViduReferenceVideoNode": {
|
||||
"display_name": "Vidu Reference To Video Generation",
|
||||
"description": "Generate video from multiple images and prompt",
|
||||
"description": "Generate video from multiple images and a prompt",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
@@ -14454,7 +14650,7 @@
|
||||
},
|
||||
"ViduTextToVideoNode": {
|
||||
"display_name": "Vidu Text To Video Generation",
|
||||
"description": "Generate video from text prompt",
|
||||
"description": "Generate video from a text prompt",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
@@ -15365,6 +15561,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanReferenceVideoApi": {
|
||||
"display_name": "Wan Reference to Video",
|
||||
"description": "Use the character and voice from input videos, combined with a prompt, to generate a new video that maintains character consistency.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt describing the elements and visual features. Supports English and Chinese. Use identifiers such as `character1` and `character2` to refer to the reference characters."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Negative prompt describing what to avoid."
|
||||
},
|
||||
"reference_videos": {
|
||||
"name": "reference_videos"
|
||||
},
|
||||
"size": {
|
||||
"name": "size"
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed"
|
||||
},
|
||||
"shot_type": {
|
||||
"name": "shot_type",
|
||||
"tooltip": "Specifies the shot type for the generated video, that is, whether the video is a single continuous shot or multiple shots with cuts."
|
||||
},
|
||||
"watermark": {
|
||||
"name": "watermark",
|
||||
"tooltip": "Whether to add an AI-generated watermark to the result."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanSoundImageToVideo": {
|
||||
"display_name": "WanSoundImageToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -369,6 +369,14 @@
|
||||
"Comfy_TreeExplorer_ItemPadding": {
|
||||
"name": "Tree explorer item padding"
|
||||
},
|
||||
"Comfy_UI_TabBarLayout": {
|
||||
"name": "Tab Bar Layout",
|
||||
"tooltip": "Controls the layout of the tab bar. \"Integrated\" moves Help and User controls into the tab bar area.",
|
||||
"options": {
|
||||
"Default": "Default",
|
||||
"Integrated": "Integrated"
|
||||
}
|
||||
},
|
||||
"Comfy_UseNewMenu": {
|
||||
"name": "Use new menu",
|
||||
"tooltip": "Enable the redesigned top menu bar.",
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
{
|
||||
"Comfy-Desktop_CheckForUpdates": {
|
||||
"label": "Buscar actualizaciones"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
|
||||
"label": "Abrir carpeta de nodos personalizados"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenInputsFolder": {
|
||||
"label": "Abrir carpeta de entradas"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenLogsFolder": {
|
||||
"label": "Abrir carpeta de registros"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelConfig": {
|
||||
"label": "Abrir extra_model_paths.yaml"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelsFolder": {
|
||||
"label": "Abrir carpeta de modelos"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenOutputsFolder": {
|
||||
"label": "Abrir carpeta de salidas"
|
||||
},
|
||||
"Comfy-Desktop_OpenDevTools": {
|
||||
"label": "Abrir herramientas de desarrollo"
|
||||
},
|
||||
"Comfy-Desktop_OpenUserGuide": {
|
||||
"label": "Guía de usuario de escritorio"
|
||||
},
|
||||
"Comfy-Desktop_Quit": {
|
||||
"label": "Salir"
|
||||
},
|
||||
"Comfy-Desktop_Reinstall": {
|
||||
"label": "Reinstalar"
|
||||
},
|
||||
"Comfy-Desktop_Restart": {
|
||||
"label": "Reiniciar"
|
||||
},
|
||||
"Comfy_3DViewer_Open3DViewer": {
|
||||
"label": "Abrir visor 3D (Beta) para el nodo seleccionado"
|
||||
},
|
||||
@@ -155,18 +191,30 @@
|
||||
"Comfy_Manager_ShowUpdateAvailablePacks": {
|
||||
"label": "Buscar actualizaciones"
|
||||
},
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "Alternar diálogo de progreso del administrador"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Decrease": {
|
||||
"label": "Disminuir tamaño del pincel en MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Increase": {
|
||||
"label": "Aumentar tamaño del pincel en MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_ColorPicker": {
|
||||
"label": "Abrir selector de color en MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_Mirror_Horizontal": {
|
||||
"label": "Espejar horizontalmente en MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_Mirror_Vertical": {
|
||||
"label": "Espejar verticalmente en MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "Abrir editor de máscara para el nodo seleccionado"
|
||||
},
|
||||
"Comfy_MaskEditor_Rotate_Left": {
|
||||
"label": "Girar a la izquierda en MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_Rotate_Right": {
|
||||
"label": "Girar a la derecha en MaskEditor"
|
||||
},
|
||||
"Comfy_Memory_UnloadModels": {
|
||||
"label": "Descargar modelos"
|
||||
},
|
||||
@@ -197,6 +245,9 @@
|
||||
"Comfy_QueueSelectedOutputNodes": {
|
||||
"label": "Encolar nodos de salida seleccionados"
|
||||
},
|
||||
"Comfy_Queue_ToggleOverlay": {
|
||||
"label": "Alternar historial de trabajos"
|
||||
},
|
||||
"Comfy_Redo": {
|
||||
"label": "Rehacer"
|
||||
},
|
||||
@@ -221,6 +272,12 @@
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "Centro de ayuda"
|
||||
},
|
||||
"Comfy_ToggleLinear": {
|
||||
"label": "alternar modo lineal"
|
||||
},
|
||||
"Comfy_ToggleQPOV2": {
|
||||
"label": "Alternar Panel de Cola V2"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Cambiar Tema (Oscuro/Claro)"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user