mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-17 02:47:35 +00:00
Compare commits
1 Commits
fix/codera
...
remove-cac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04225dd49 |
@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (see `.nvmrc` for the required version) and pnpm
|
||||
- Node.js (see `.nvmrc`, currently v24) and pnpm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
|
||||
|
||||
@@ -87,10 +87,6 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
|
||||
> ⚠️ IMPORTANT:
|
||||
> The dev server will NOT load JavaScript extensions from custom nodes. Only core extensions (built into the frontend) will be loaded. This is because the shim system that allows custom node JavaScript to import frontend modules only works in production builds. Python custom nodes still function normally. See [Extension Development Guide](docs/extensions/development.md) for details and workarounds. And See [Extension Overview](docs/extensions/README.md) for extensions overview.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you run into issues during development (e.g. `pnpm dev` hanging, TypeScript errors after pulling, lock file conflicts), see [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common fixes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
This guide helps you resolve common issues when developing ComfyUI Frontend.
|
||||
|
||||
## Quick Diagnostic Flowchart
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Having Issues?] --> B{What's the problem?}
|
||||
B -->|Dev server stuck| C[nx serve hangs]
|
||||
B -->|Build errors| D[Check build issues]
|
||||
B -->|Lint errors| Q[Check linting issues]
|
||||
B -->|Dependency issues| E[Package problems]
|
||||
B -->|Other| F[See FAQ below]
|
||||
|
||||
Q --> R{oxlint or ESLint?}
|
||||
R -->|oxlint| S[Check .oxlintrc.json<br/>and run pnpm lint:fix]
|
||||
R -->|ESLint| T[Check eslint.config.ts<br/>and run pnpm lint:fix]
|
||||
S --> L
|
||||
T --> L
|
||||
|
||||
C --> G{Tried quick fixes?}
|
||||
G -->|No| H[Run: pnpm i]
|
||||
G -->|Still stuck| I[Run: pnpm clean]
|
||||
I --> J{Still stuck?}
|
||||
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
|
||||
J -->|No| L[Fixed!]
|
||||
H --> L
|
||||
|
||||
D --> M[Run: pnpm build]
|
||||
M --> N{Build succeeds?}
|
||||
N -->|No| O[Check error messages<br/>in FAQ]
|
||||
N -->|Yes| L
|
||||
|
||||
E --> H
|
||||
|
||||
F --> P[Search FAQ or<br/>ask in Discord]
|
||||
```
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Development Server Issues
|
||||
|
||||
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Command hangs on "nx serve"
|
||||
- Dev server doesn't respond
|
||||
- Terminal appears frozen
|
||||
|
||||
**Solutions (try in order):**
|
||||
|
||||
1. **First attempt - Reinstall dependencies:**
|
||||
|
||||
```bash
|
||||
pnpm i
|
||||
```
|
||||
|
||||
2. **Second attempt - Clean build cache:**
|
||||
|
||||
```bash
|
||||
pnpm clean
|
||||
```
|
||||
|
||||
3. **Last resort - Full node_modules reset:**
|
||||
```bash
|
||||
pnpm dlx rimraf node_modules && pnpm i
|
||||
```
|
||||
|
||||
**Why this happens:**
|
||||
|
||||
- Corrupted dependency cache
|
||||
- Outdated lock files after branch switching
|
||||
- Incomplete previous installations
|
||||
- NX cache corruption
|
||||
|
||||
---
|
||||
|
||||
#### Q: Port conflicts - "Address already in use"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Error: `EADDRINUSE` or "port already in use"
|
||||
- Dev server fails to start
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Find and kill the process using the port:**
|
||||
|
||||
```bash
|
||||
# On Linux/Mac
|
||||
lsof -ti:5173 | xargs kill -9
|
||||
|
||||
# On Windows
|
||||
netstat -ano | findstr :5173
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
2. **Use a different port** by adding a `port` option to the `server` block in `vite.config.mts`:
|
||||
```ts
|
||||
server: {
|
||||
port: 3000,
|
||||
// ...existing config
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Build and Type Issues
|
||||
|
||||
#### Q: TypeScript errors after pulling latest changes
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Type errors in files you didn't modify
|
||||
- "Cannot find module" errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Rebuild TypeScript references:**
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
2. **Clean and reinstall:**
|
||||
|
||||
```bash
|
||||
pnpm clean && pnpm i
|
||||
```
|
||||
|
||||
3. **Restart your IDE's TypeScript server**
|
||||
- VS Code: `Cmd/Ctrl + Shift + P` → "TypeScript: Restart TS Server"
|
||||
|
||||
---
|
||||
|
||||
#### Q: "Workspace not found" or monorepo errors
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- pnpm can't find workspace packages
|
||||
- Import errors between packages
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Verify you're in the project root:**
|
||||
|
||||
```bash
|
||||
pwd # Should be in ComfyUI_frontend/
|
||||
```
|
||||
|
||||
2. **Rebuild workspace:**
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Linting Issues (oxlint)
|
||||
|
||||
#### Q: `eslint-disable` comment isn't suppressing an oxlint rule
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- `// eslint-disable-next-line rule-name` has no effect
|
||||
- Lint error persists despite the disable comment
|
||||
|
||||
**Solution:**
|
||||
|
||||
oxlint has its own disable syntax. Use `oxlint-disable` instead:
|
||||
|
||||
```ts
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log('debug')
|
||||
```
|
||||
|
||||
Check whether the rule is enforced by oxlint (in `.oxlintrc.json`) or ESLint (in `eslint.config.ts`) to pick the right disable comment.
|
||||
|
||||
---
|
||||
|
||||
#### Q: New lint errors after pulling/upgrading oxlint
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Lint errors in files you didn't change
|
||||
- Rules you haven't seen before (e.g. `no-immediate-mutation`, `prefer-optional-chain`)
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Run the auto-fixer first:**
|
||||
|
||||
```bash
|
||||
pnpm lint:fix
|
||||
```
|
||||
|
||||
2. **Review changes carefully** — some oxlint auto-fixes can produce incorrect code. Check the diff before committing.
|
||||
|
||||
3. **If a rule seems wrong**, check `.oxlintrc.json` to see if it should be disabled or configured differently.
|
||||
|
||||
**Why this happens:** oxlint version bumps often enable new rules by default.
|
||||
|
||||
---
|
||||
|
||||
#### Q: oxlint fails with TypeScript errors
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- `pnpm oxlint` or `pnpm lint` fails with type-related errors
|
||||
- Errors mention type resolution or missing type information
|
||||
|
||||
**Solution:**
|
||||
|
||||
oxlint runs with `--type-aware` in this project, which requires valid TypeScript compilation. Fix the TS errors first:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # Identify TS errors
|
||||
pnpm build # Or do a full build
|
||||
pnpm lint # Then re-run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Q: Duplicate lint errors from both oxlint and ESLint
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Same violation reported twice
|
||||
- Conflicting auto-fix suggestions
|
||||
|
||||
**Solution:**
|
||||
|
||||
The project uses `eslint-plugin-oxlint` to automatically disable ESLint rules that oxlint already covers (see `eslint.config.ts`). If you see duplicates:
|
||||
|
||||
1. Ensure `.oxlintrc.json` is up to date after adding new oxlint rules
|
||||
2. Run `pnpm lint` (which runs oxlint then ESLint in sequence) rather than running them individually
|
||||
|
||||
---
|
||||
|
||||
### Dependency and Package Issues
|
||||
|
||||
#### Q: "Package not found" after adding a dependency
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Module not found after `pnpm add`
|
||||
- Import errors for newly installed packages
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Ensure you installed in the correct workspace** (see `pnpm-workspace.yaml` for available workspaces):
|
||||
|
||||
```bash
|
||||
# Example: install in a specific workspace
|
||||
pnpm --filter <workspace-name> add <package>
|
||||
```
|
||||
|
||||
2. **Clear pnpm cache:**
|
||||
```bash
|
||||
pnpm store prune
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Q: Lock file conflicts after merge/rebase
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Git conflicts in `pnpm-lock.yaml`
|
||||
- Dependency resolution errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Regenerate lock file:**
|
||||
|
||||
```bash
|
||||
rm pnpm-lock.yaml
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Or accept upstream lock file:**
|
||||
```bash
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Testing Issues
|
||||
|
||||
#### Q: Tests fail locally but pass in CI
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Flaky tests
|
||||
- Different results between local and CI
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Run tests in CI mode:**
|
||||
|
||||
```bash
|
||||
CI=true pnpm test:unit
|
||||
```
|
||||
|
||||
2. **Clear test cache:**
|
||||
|
||||
```bash
|
||||
pnpm test:unit --no-cache
|
||||
```
|
||||
|
||||
3. **Check Node version matches CI** (see `.nvmrc` for the required version):
|
||||
```bash
|
||||
node --version
|
||||
nvm use # If using nvm — reads .nvmrc automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Git and Branch Issues
|
||||
|
||||
#### Q: Changes from another branch appearing in my branch
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Uncommitted changes not related to your work
|
||||
- Dirty working directory
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Stash and reinstall:**
|
||||
|
||||
```bash
|
||||
git stash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Check for untracked files:**
|
||||
```bash
|
||||
git status
|
||||
git clean -fd # Careful: removes untracked files!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
1. **Search existing issues:** [GitHub Issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues)
|
||||
2. **Ask the community:** [Discord](https://discord.com/invite/comfyorg) (navigate to the `#dev-frontend` channel)
|
||||
3. **Create a new issue:** Include:
|
||||
- Your OS and Node version (`node --version`)
|
||||
- Steps to reproduce
|
||||
- Full error message
|
||||
- What you've already tried
|
||||
|
||||
## Contributing to This Guide
|
||||
|
||||
Found a solution to a common problem? Please:
|
||||
|
||||
1. Open a PR to add it to this guide
|
||||
2. Follow the FAQ format above
|
||||
3. Include the symptoms, solutions, and why it happens
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-03-10
|
||||
@@ -27,7 +27,7 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have the Node.js version specified in `.nvmrc` installed.
|
||||
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
|
||||
Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1420,6 +1420,15 @@ audio.comfy-audio.empty-audio-widget {
|
||||
font-size 0.1s ease;
|
||||
}
|
||||
|
||||
/* Performance optimization during canvas interaction */
|
||||
.transform-pane--interacting .lg-node * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.transform-pane--interacting .lg-node {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
@@ -59,10 +59,7 @@ function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
|
||||
}
|
||||
|
||||
const pathPlusQueryParams = api.apiURL(
|
||||
'/view?' +
|
||||
params.toString() +
|
||||
app.getPreviewFormatParam() +
|
||||
app.getRandParam()
|
||||
'/view?' + params.toString() + app.getPreviewFormatParam()
|
||||
)
|
||||
const imageElement = new Image()
|
||||
imageElement.crossOrigin = 'anonymous'
|
||||
|
||||
@@ -17,7 +17,7 @@ type MockTask = {
|
||||
executionEndTimestamp?: number
|
||||
previewOutput?: {
|
||||
isImage: boolean
|
||||
urlWithTimestamp: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ describe(useQueueNotificationBanners, () => {
|
||||
if (previewUrl) {
|
||||
task.previewOutput = {
|
||||
isImage,
|
||||
urlWithTimestamp: previewUrl
|
||||
url: previewUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ export const useQueueNotificationBanners = () => {
|
||||
completedCount++
|
||||
const preview = task.previewOutput
|
||||
if (preview?.isImage) {
|
||||
imagePreviews.push(preview.urlWithTimestamp)
|
||||
imagePreviews.push(preview.url)
|
||||
}
|
||||
} else if (state === 'failed') {
|
||||
failedCount++
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
@@ -58,11 +57,6 @@ export function promoteWidget(
|
||||
for (const parent of parents) {
|
||||
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Promoted widget "${widgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
export function demoteWidget(
|
||||
@@ -78,11 +72,6 @@ export function demoteWidget(
|
||||
for (const parent of parents) {
|
||||
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Demoted widget "${widgetName}" on node ${node.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
function getParentNodes(): SubgraphNode[] {
|
||||
@@ -315,9 +304,4 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
type ImageCompareOutput = NodeOutputWith<{
|
||||
@@ -24,11 +23,10 @@ useExtensionService().registerExtension({
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const { a_images: aImages, b_images: bImages } = output
|
||||
const rand = app.getRandParam()
|
||||
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
const params = new URLSearchParams(record)
|
||||
return api.apiURL(`/view?${params}${rand}`)
|
||||
return api.apiURL(`/view?${params}`)
|
||||
}
|
||||
|
||||
const beforeImages =
|
||||
|
||||
@@ -2,7 +2,6 @@ import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
class Load3dUtils {
|
||||
static async generateThumbnailIfNeeded(
|
||||
@@ -133,8 +132,7 @@ class Load3dUtils {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
'subfolder=' + subfolder
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
|
||||
@@ -119,31 +119,17 @@ vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
|
||||
getAssetType: mockGetAssetType
|
||||
}))
|
||||
|
||||
const mockGetOutputAssetMetadata = vi.hoisted(() =>
|
||||
vi.fn().mockReturnValue(null)
|
||||
)
|
||||
vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: mockGetOutputAssetMetadata
|
||||
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
const mockCreateAssetExport = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
|
||||
)
|
||||
vi.mock('../services/assetService', () => ({
|
||||
assetService: {
|
||||
deleteAsset: mockDeleteAsset,
|
||||
createAssetExport: mockCreateAssetExport
|
||||
deleteAsset: mockDeleteAsset
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTrackExport = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/assetExportStore', () => ({
|
||||
useAssetExportStore: () => ({
|
||||
trackExport: mockTrackExport
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
deleteItem: vi.fn(),
|
||||
@@ -273,106 +259,6 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadMultipleAssets - job_asset_name_filters', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockCreateAssetExport.mockClear()
|
||||
mockTrackExport.mockClear()
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockImplementation(
|
||||
(meta: Record<string, unknown> | undefined) =>
|
||||
meta && 'jobId' in meta ? meta : null
|
||||
)
|
||||
})
|
||||
|
||||
function createOutputAsset(
|
||||
id: string,
|
||||
name: string,
|
||||
jobId: string,
|
||||
outputCount?: number
|
||||
): AssetItem {
|
||||
return createMockAsset({
|
||||
id,
|
||||
name,
|
||||
tags: ['output'],
|
||||
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
|
||||
})
|
||||
}
|
||||
|
||||
it('should omit name filters for job-level selections (outputCount known)', async () => {
|
||||
const assets = [
|
||||
createOutputAsset('a1', 'img1.png', 'job1', 3),
|
||||
createOutputAsset('a2', 'img2.png', 'job1', 3),
|
||||
createOutputAsset('a3', 'img3.png', 'job1', 3)
|
||||
]
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets(assets)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_ids).toEqual(['job1'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should omit name filters for multiple job-level selections', async () => {
|
||||
const j1a = createOutputAsset('a1', 'out1a.png', 'job1', 2)
|
||||
const j1b = createOutputAsset('a2', 'out1b.png', 'job1', 2)
|
||||
const j2 = createOutputAsset('a3', 'out2.png', 'job2', 1)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([j1a, j1b, j2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_ids).toEqual(['job1', 'job2'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include name filters when outputCount is unknown', async () => {
|
||||
const asset1 = createOutputAsset('a1', 'img1.png', 'job1')
|
||||
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([asset1, asset2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_asset_name_filters).toEqual({
|
||||
job1: ['img1.png'],
|
||||
job2: ['img2.png']
|
||||
})
|
||||
})
|
||||
|
||||
it('should mix: omit filters for known outputCount, keep for unknown', async () => {
|
||||
const j1a = createOutputAsset('a1', 'img1a.png', 'job1', 2)
|
||||
const j1b = createOutputAsset('a2', 'img1b.png', 'job1', 2)
|
||||
const j2 = createOutputAsset('a3', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([j1a, j1b, j2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_ids).toEqual(['job1', 'job2'])
|
||||
expect(payload.job_asset_name_filters).toEqual({
|
||||
job2: ['img2.png']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - model cache invalidation', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
|
||||
@@ -146,10 +146,7 @@ export function useMediaAssetActions() {
|
||||
if (!jobIds.includes(jobId)) {
|
||||
jobIds.push(jobId)
|
||||
}
|
||||
// Only add name filters when outputCount is unknown.
|
||||
// When outputCount is set, the asset is a job-level selection
|
||||
// from the gallery and the user wants all outputs for that job.
|
||||
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
|
||||
if (metadata?.jobId && asset.name) {
|
||||
if (!jobAssetNameFilters[metadata.jobId]) {
|
||||
jobAssetNameFilters[metadata.jobId] = []
|
||||
}
|
||||
|
||||
@@ -50,12 +50,6 @@ vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
|
||||
vi.mock('posthog-js', () => hoisted.mockPosthog)
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
subscriptionTier: { value: null }
|
||||
})
|
||||
}))
|
||||
|
||||
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
|
||||
|
||||
function createProvider(
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
@@ -120,7 +119,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
this.setSubscriptionProperties()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -213,19 +211,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private setSubscriptionProperties(): void {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
watch(
|
||||
subscriptionTier,
|
||||
(tier) => {
|
||||
if (tier && this.posthog) {
|
||||
this.posthog.people.set({ subscription_tier: tier })
|
||||
}
|
||||
},
|
||||
{ immediate: true, once: true }
|
||||
)
|
||||
}
|
||||
|
||||
trackSignupOpened(): void {
|
||||
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,21 @@ describe('TransformPane', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove event listeners on unmount', async () => {
|
||||
@@ -151,10 +166,43 @@ describe('TransformPane', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction state management', () => {
|
||||
it('should apply interacting class during interactions', async () => {
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate interaction start by checking internal state
|
||||
// Note: This tests the CSS class application logic
|
||||
const transformPane = wrapper.find('[data-testid="transform-pane"]')
|
||||
|
||||
// Initially should not have interacting class
|
||||
expect(transformPane.classes()).not.toContain(
|
||||
'transform-pane--interacting'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle pointer events for node delegation', async () => {
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
ref="transformPaneRef"
|
||||
data-testid="transform-pane"
|
||||
class="pointer-events-none absolute inset-0 size-full will-change-auto"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 size-full',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
@@ -11,11 +16,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, useTemplateRef, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
@@ -27,32 +33,7 @@ const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 256
|
||||
})
|
||||
|
||||
const transformPaneRef = useTemplateRef('transformPaneRef')
|
||||
|
||||
/**
|
||||
* Apply transform style and will-change class via direct DOM mutation
|
||||
* instead of reactive template bindings (:style / :class).
|
||||
*
|
||||
* These values change every animation frame during zoom or pan.
|
||||
* If they were bound in the template, Vue would diff the entire
|
||||
* TransformPane vnode—including all child node slots—on every frame,
|
||||
* causing expensive vdom patch work across the full node list.
|
||||
* Mutating the DOM directly limits the update to a single element.
|
||||
*/
|
||||
|
||||
watch([transformStyle, transformPaneRef], ([newStyle, el]) => {
|
||||
if (el) {
|
||||
Object.assign(el.style, newStyle)
|
||||
}
|
||||
})
|
||||
watch([isInteracting, transformPaneRef], ([interacting, el]) => {
|
||||
if (el) {
|
||||
el.classList.toggle('will-change-transform', interacting)
|
||||
el.classList.toggle('will-change-auto', !interacting)
|
||||
}
|
||||
settleDelay: 16
|
||||
})
|
||||
|
||||
useRafFn(
|
||||
@@ -65,3 +46,9 @@ useRafFn(
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-pane--interacting {
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,40 +69,11 @@ describe('useTransformSettling', () => {
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should track pointer drag as pan interaction', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not treat right-click as pan', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
element.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 2 })
|
||||
)
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pointermove without pointerdown', async () => {
|
||||
it('should not track pan events', async () => {
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
// Pointer events should not trigger transform
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
interface TransformSettlingOptions {
|
||||
/**
|
||||
* Delay in ms before transform is considered "settled" after last interaction
|
||||
* @default 256
|
||||
* @default 200
|
||||
*/
|
||||
settleDelay?: number
|
||||
/**
|
||||
@@ -16,10 +16,10 @@ interface TransformSettlingOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when canvas transforms (zoom or pan) are actively changing vs settled.
|
||||
* Tracks when canvas zoom transforms are actively changing vs settled.
|
||||
*
|
||||
* This composable helps optimize rendering quality during transform interactions.
|
||||
* When the user is actively zooming or panning, we can reduce rendering quality
|
||||
* This composable helps optimize rendering quality during zoom transformations.
|
||||
* When the user is actively zooming, we can reduce rendering quality
|
||||
* for better performance. Once the transform "settles" (stops changing), we can
|
||||
* trigger high-quality re-rasterization.
|
||||
*
|
||||
@@ -50,72 +50,35 @@ export function useTransformSettling(
|
||||
|
||||
const isTransforming = ref(false)
|
||||
|
||||
/**
|
||||
* Mark transform as active
|
||||
*/
|
||||
const markTransformActive = () => {
|
||||
isTransforming.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transform as settled (debounced)
|
||||
*/
|
||||
const markTransformSettled = useDebounceFn(() => {
|
||||
isTransforming.value = false
|
||||
}, settleDelay)
|
||||
|
||||
function markInteracting() {
|
||||
isTransforming.value = true
|
||||
/**
|
||||
* Handle zoom transform event - mark active then queue settle
|
||||
*/
|
||||
const handleWheel = () => {
|
||||
markTransformActive()
|
||||
void markTransformSettled()
|
||||
}
|
||||
|
||||
const eventOptions = { capture: true, passive }
|
||||
|
||||
useEventListener(target, 'wheel', markInteracting, eventOptions)
|
||||
usePointerDrag(target, markInteracting, eventOptions)
|
||||
// Register wheel event listener with auto-cleanup
|
||||
useEventListener(target, 'wheel', handleWheel, {
|
||||
capture: true,
|
||||
passive
|
||||
})
|
||||
|
||||
return {
|
||||
isTransforming
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onDrag` on each pointermove while a pointer is held down.
|
||||
*/
|
||||
function usePointerDrag(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
onDrag: () => void,
|
||||
eventOptions: AddEventListenerOptions
|
||||
) {
|
||||
/** Number of active pointers (supports multi-touch correctly). */
|
||||
const pointerCount = ref(0)
|
||||
|
||||
useEventListener(
|
||||
target,
|
||||
'pointerdown',
|
||||
(e: PointerEvent) => {
|
||||
// Only primary (0) and middle (1) buttons trigger canvas pan.
|
||||
if (e.button === 0 || e.button === 1) pointerCount.value++
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
target,
|
||||
'pointermove',
|
||||
() => {
|
||||
if (pointerCount.value > 0) onDrag()
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
// Listen on window so the release is caught even if the pointer
|
||||
// leaves the canvas before the button is released.
|
||||
useEventListener(
|
||||
window,
|
||||
'pointerup',
|
||||
() => {
|
||||
if (pointerCount.value > 0) pointerCount.value--
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'pointercancel',
|
||||
() => {
|
||||
if (pointerCount.value > 0) pointerCount.value--
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('useTransformState', () => {
|
||||
it('should generate correct initial transform style', () => {
|
||||
const { transformStyle } = transformState
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale3d(1, 1, 1) translate3d(0px, 0px, 0)',
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,7 @@ describe('useTransformState', () => {
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale3d(0.5, 0.5, 0.5) translate3d(150px, 75px, 0)',
|
||||
transform: 'scale(0.5) translate(150px, 75px)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,9 +79,7 @@ function useTransformStateIndividual() {
|
||||
// ctx.scale(scale); ctx.translate(offset)
|
||||
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
|
||||
// Effective mapping: screen = (canvas + offset) * scale
|
||||
// Using the 3D versions of scale and translate can provide a smoother experience
|
||||
// when dealing with a large number of nodes.
|
||||
transform: `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x}px, ${camera.y}px, 0)`,
|
||||
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTransformCompatOverlayProps } from '@/composables/useTransformCompat
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import {
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
@@ -34,9 +33,9 @@ import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/compo
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
@@ -69,8 +68,7 @@ const modelValue = defineModel<string | undefined>({
|
||||
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
@@ -149,28 +147,36 @@ const inputItems = computed<FormDropdownItem[]>(() => {
|
||||
label: getDisplayLabel(String(value))
|
||||
}))
|
||||
})
|
||||
function assetKindToMediaType(kind: AssetKind): string {
|
||||
return kind === 'mesh' ? '3D' : kind
|
||||
}
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return []
|
||||
if (!['image', 'video', 'mesh'].includes(props.assetKind ?? '')) return []
|
||||
|
||||
const targetMediaType = assetKindToMediaType(props.assetKind!)
|
||||
const outputFiles = outputMediaAssets.media.value.filter(
|
||||
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
|
||||
)
|
||||
const outputs = new Set<string>()
|
||||
|
||||
return outputFiles.map((asset) => {
|
||||
const annotatedPath = `${asset.name} [output]`
|
||||
return {
|
||||
id: `output-${annotatedPath}`,
|
||||
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(annotatedPath)
|
||||
}
|
||||
// Extract output images/videos from queue history
|
||||
queueStore.historyTasks.forEach((task) => {
|
||||
task.flatOutputs.forEach((output) => {
|
||||
const isTargetType =
|
||||
(props.assetKind === 'image' && output.mediaType === 'images') ||
|
||||
(props.assetKind === 'video' && output.mediaType === 'video') ||
|
||||
(props.assetKind === 'mesh' && output.is3D)
|
||||
|
||||
if (output.type === 'output' && isTargetType) {
|
||||
const path = output.subfolder
|
||||
? `${output.subfolder}/${output.filename}`
|
||||
: output.filename
|
||||
// Add [output] annotation so the preview component knows the type
|
||||
const annotatedPath = `${path} [output]`
|
||||
outputs.add(annotatedPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(outputs).map((output) => ({
|
||||
id: `output-${output}`,
|
||||
preview_url: getMediaUrl(output.replace(' [output]', ''), 'output'),
|
||||
name: output,
|
||||
label: getDisplayLabel(output)
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -459,18 +465,11 @@ function getMediaUrl(
|
||||
filename: string,
|
||||
type: 'input' | 'output' = 'input'
|
||||
): string {
|
||||
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
|
||||
return ''
|
||||
if (!['image', 'video'].includes(props.assetKind ?? '')) return ''
|
||||
const params = new URLSearchParams({ filename, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return `/api/view?${params}`
|
||||
}
|
||||
|
||||
function handleIsOpenUpdate(isOpen: boolean) {
|
||||
if (isOpen && !outputMediaAssets.loading.value) {
|
||||
void outputMediaAssets.refresh()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -496,7 +495,6 @@ function handleIsOpenUpdate(isOpen: boolean) {
|
||||
class="w-full"
|
||||
@update:selected="updateSelectedItems"
|
||||
@update:files="handleFilesUpdate"
|
||||
@update:is-open="handleIsOpenUpdate"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
@@ -90,11 +90,11 @@ const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected', {
|
||||
const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
|
||||
default: () => new Set()
|
||||
})
|
||||
const isOpen = defineModel<boolean>('isOpen', { default: false })
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const popoverRef = ref<InstanceType<typeof Popover>>()
|
||||
const triggerRef = useTemplateRef('triggerRef')
|
||||
const isOpen = ref(false)
|
||||
|
||||
const maxSelectable = computed(() => {
|
||||
if (multiple === true) return Infinity
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Format time in MM:SS format
|
||||
@@ -20,8 +19,7 @@ export function getResourceURL(
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
'type=' + type,
|
||||
'subfolder=' + subfolder,
|
||||
app.getRandParam().substring(1)
|
||||
'subfolder=' + subfolder
|
||||
].join('&')
|
||||
|
||||
return `/view?${params}`
|
||||
|
||||
@@ -382,11 +382,6 @@ export class ComfyApp {
|
||||
else return ''
|
||||
}
|
||||
|
||||
getRandParam() {
|
||||
if (isCloud) return ''
|
||||
return '&rand=' + Math.random()
|
||||
}
|
||||
|
||||
static onClipspaceEditorSave() {
|
||||
if (ComfyApp.clipspace_return_node) {
|
||||
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)
|
||||
|
||||
@@ -136,140 +136,6 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodeExecutionIdToNodeLocatorId.mockReset()
|
||||
mockNodeIdToNodeLocatorId.mockReset()
|
||||
mockNodeLocatorIdToNodeExecutionId.mockReset()
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('should resolve execution IDs to locator IDs for subgraph nodes', () => {
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
nodes: []
|
||||
}
|
||||
const mockNode = createMockLGraphNode({
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
})
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123:456',
|
||||
state: 'running',
|
||||
value: 50,
|
||||
max: 100,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.nodeLocationProgressStates
|
||||
|
||||
expect(result['123']).toBeDefined()
|
||||
expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:456']).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not re-traverse graph for same execution IDs across progress updates', () => {
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
nodes: []
|
||||
}
|
||||
const mockNode = createMockLGraphNode({
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
})
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123:456',
|
||||
state: 'running',
|
||||
value: 50,
|
||||
max: 100,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
}
|
||||
}
|
||||
|
||||
// First evaluation triggers graph traversal
|
||||
expect(store.nodeLocationProgressStates['123']).toBeDefined()
|
||||
const callCountAfterFirst = vi.mocked(app.rootGraph.getNodeById).mock.calls
|
||||
.length
|
||||
|
||||
// Second update with same execution IDs but different progress
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123:456',
|
||||
state: 'running',
|
||||
value: 75,
|
||||
max: 100,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.nodeLocationProgressStates['123']).toBeDefined()
|
||||
|
||||
// getNodeById should NOT be called again for the same execution ID
|
||||
expect(vi.mocked(app.rootGraph.getNodeById).mock.calls.length).toBe(
|
||||
callCountAfterFirst
|
||||
)
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple sibling nodes in the same subgraph', () => {
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
nodes: []
|
||||
}
|
||||
const mockNode = createMockLGraphNode({
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
})
|
||||
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
// Two sibling nodes in the same subgraph
|
||||
store.nodeProgressStates = {
|
||||
node1: {
|
||||
display_node_id: '123:456',
|
||||
state: 'running',
|
||||
value: 50,
|
||||
max: 100,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node1'
|
||||
},
|
||||
node2: {
|
||||
display_node_id: '123:789',
|
||||
state: 'running',
|
||||
value: 30,
|
||||
max: 100,
|
||||
prompt_id: 'test',
|
||||
node_id: 'node2'
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.nodeLocationProgressStates
|
||||
|
||||
// Both sibling nodes should be resolved with the correct subgraph UUID
|
||||
expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:456']).toBeDefined()
|
||||
expect(result['a1b2c3d4-e5f6-7890-abcd-ef1234567890:789']).toBeDefined()
|
||||
|
||||
// The shared parent "123" should also have a merged state
|
||||
expect(result['123']).toBeDefined()
|
||||
expect(result['123'].state).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
|
||||
@@ -73,24 +73,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
const initializingJobIds = ref<Set<string>>(new Set())
|
||||
|
||||
/**
|
||||
* Cache for executionIdToNodeLocatorId lookups.
|
||||
* Avoids redundant graph traversals during a single execution run.
|
||||
* Cleared at execution start and end to ensure fresh graph state.
|
||||
*/
|
||||
const executionIdToLocatorCache = new Map<string, NodeLocatorId | undefined>()
|
||||
|
||||
function cachedExecutionIdToLocator(
|
||||
executionId: string
|
||||
): NodeLocatorId | undefined {
|
||||
if (executionIdToLocatorCache.has(executionId)) {
|
||||
return executionIdToLocatorCache.get(executionId)
|
||||
}
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
executionIdToLocatorCache.set(executionId, locatorId)
|
||||
return locatorId
|
||||
}
|
||||
|
||||
const mergeExecutionProgressStates = (
|
||||
currentState: NodeProgressState | undefined,
|
||||
newState: NodeProgressState
|
||||
@@ -130,7 +112,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const parts = String(state.display_node_id).split(':')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const executionId = parts.slice(0, i + 1).join(':')
|
||||
const locatorId = cachedExecutionIdToLocator(executionId)
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!locatorId) continue
|
||||
|
||||
result[locatorId] = mergeExecutionProgressStates(
|
||||
@@ -238,7 +220,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
executionIdToLocatorCache.clear()
|
||||
executionErrorStore.clearAllErrors()
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
@@ -456,7 +437,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
* Reset execution-related state after a run completes or is stopped.
|
||||
*/
|
||||
function resetExecutionState(jobIdParam?: string | null) {
|
||||
executionIdToLocatorCache.clear()
|
||||
nodeProgressStates.value = {}
|
||||
const jobId = jobIdParam ?? activeJobId.value ?? null
|
||||
if (jobId) {
|
||||
|
||||
@@ -117,12 +117,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const outputs = getNodeOutputs(node)
|
||||
if (!outputs?.images?.length) return
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const params = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
return api.apiURL(`/view?${params}${previewParam}`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -104,10 +104,6 @@ export class ResultItemImpl {
|
||||
return api.apiURL('/view?' + params)
|
||||
}
|
||||
|
||||
get urlWithTimestamp(): string {
|
||||
return `${this.url}&t=${+new Date()}`
|
||||
}
|
||||
|
||||
get isVhsFormat(): boolean {
|
||||
return !!this.format && !!this.frame_rate
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user