Compare commits

..

8 Commits

Author SHA1 Message Date
CodeRabbit Fixer
b0210259e3 fix: followup: add once option to subscription tier watcher in PostHogTelemetryProvider (#9835)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:07:15 +01:00
Robin Huang
e5c8d26061 feat: set subscription tier as PostHog user property (#9764)
Sets subscription_tier as a PostHog person property via people.set after
user identification. Watches for async subscription status resolution so
the property updates even if the API responds after identify.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9764-feat-set-subscription-tier-as-PostHog-user-property-3216d73d3650812f8afcee0a830e434d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 14:47:14 -07:00
Simon Pinfold
37ff065061 fix: omit job_asset_name_filters when all job outputs selected (#9684)
## Summary

- When bulk exporting, `job_asset_name_filters` was always sent for
every job, restricting each job to only the assets the user clicked on.
For multi-output jobs, this meant the ZIP only contained 1 asset per job
instead of all outputs.
- Now compares selected asset count per job against `outputCount`
metadata and omits the filter for fully-selected jobs, so the backend
returns all assets.

## Test plan

- [x] Unit tests: all outputs selected → no filter sent
- [x] Unit tests: subset selected → filter sent
- [x] Unit tests: mixed selection → filter only for partial jobs
- [x] Unit tests: multiple fully-selected jobs → no filters
- [x] Typecheck, lint pass
- [ ] Manual: bulk export multi-output jobs in cloud env, verify ZIP
contains all outputs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9684-fix-omit-job_asset_name_filters-when-all-job-outputs-selected-31f6d73d3650814482f8c59d05027d79)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-12 21:31:58 +00:00
John Haugeland
5fe31e63ec Cache execution id to node locator id mappings (#9244)
## Summary

nodeLocationProgressStates runs executionIdToNodeLocatorId for every
ancestor prefix of every node's display_node_id on every WebSocket
progress update. Each call traverses the subgraph hierarchy via
getNodeById. For nested subgraphs with many executing nodes, this
results in O(N × D²) graph lookups per progress tick, where N is
the number of executing nodes and D is the nesting depth.

Add a plain Map cache inside the execution store that memoizes
executionIdToNodeLocatorId results by execution ID string. The
cache persists across computed re-evaluations within a single
execution run, reducing subsequent progress updates to O(1)
lookups per execution ID.

Cache invalidation:
- Cleared at execution start (handleExecutionStart) to ensure
  fresh graph state for each new run
- Cleared at execution end (resetExecutionState) to prevent
  stale entries leaking across runs

The cache stores strings only (no graph node references), with
typical size of ~50-200 entries per run, so memory impact is
negligible.

- **What**: Caches a mapping of ids to ids, preventing an exponential
re-scan of subgraphs
- **Breaking**: Nothing
- **Dependencies**: None

You will need to set the feature flag
`ff:expose_executionId_to_node_locator_id_cache_counters` from the
underlying counter PR if you want to measure the impact.

```js
  localStorage.setItem(
    'ff:expose_executionId_to_node_locator_id_cache_counters',
    'true'
  )
```

## Review Focus

Before pulling this PR, pull
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9243 , set the
feature flag, and run a workflow with nested subgraphs, then look in
console to see a cache measurement.

Next, pull this PR and load the same workflow. Note the massive
reduction in visits.

## Screenshots

Login problems due to cross-opener policy are preventing me from taking
screenshots from a local dev build at this time

## Thread

There isn't one.  I don't have access to AmpCode or Unito.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9244-Cache-execution-id-to-node-locator-id-mappings-3136d73d365081e680eae3c891480ee7)
by [Unito](https://www.unito.io)

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 14:13:27 -07:00
Christian Byrne
7206dea2d6 chore: add Sentry breadcrumbs to subgraph proxy widget operations (#8996)
## Summary

Add Sentry breadcrumbs to subgraph proxy widget operations for better
observability of widget state changes.

## Changes

- **What**: Add `Sentry.addBreadcrumb()` calls with category
`'subgraph'` to `promoteWidget`, `demoteWidget`, and `pruneDisconnected`
in `proxyWidgetUtils.ts`

## Review Focus

Breadcrumbs are info-level and don't affect control flow. They log
widget name/node ID for promote/demote and removed count for prune.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8996-chore-add-Sentry-breadcrumbs-to-subgraph-proxy-widget-operations-30d6d73d365081a5abbccabd39fc7129)
by [Unito](https://www.unito.io)
2026-03-12 13:51:29 -07:00
Rizumu Ayaka
8f07468fdd fix: dropdown widget fetching output files (#6734)
related
https://github.com/Comfy-Org/ComfyUI_frontend/issues/5827#issuecomment-3539629932

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6734-fix-dropdown-widget-fetching-output-files-2af6d73d365081988340f74694b7cee7)
by [Unito](https://www.unito.io)

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 13:43:33 -07:00
Comfy Org PR Bot
8b9eb5f60d docs: Add TROUBLESHOOTING.md guide for common development issues (#7738)
## Summary

Adds a comprehensive `TROUBLESHOOTING.md` guide to help developers
resolve common development issues.

## Motivation

a developer reported issues where `pnpm dev` would get stuck on 'nx
serve'. This highlighted the need for centralized troubleshooting
documentation to help developers quickly resolve common issues without
having to wait for help.

## Changes

- Created `TROUBLESHOOTING.md` with FAQ-style documentation
- Added Mermaid flowchart for quick issue diagnosis
- Documented solutions for common problems:
  - Development server issues (nx serve hanging)
  - Build and TypeScript errors
  - Dependency and package management problems
  - Testing issues
  - Git and branch conflicts

## Structure

The guide includes:
- Quick diagnostic flowchart (Mermaid)
- Frequently Asked Questions with:
  - Clear symptoms
  - Step-by-step solutions
  - Explanations of why issues occur
- Links to community support resources
- Contribution guidelines

## Test Plan

- [x] File created and committed
- [x] Mermaid flowchart renders correctly
- [x] All commands are accurate and tested
- [x] Links to Discord and GitHub are valid

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7738-docs-Add-TROUBLESHOOTING-md-guide-for-common-development-issues-2d26d73d365081eda291e619b3067bc6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 13:42:37 -07:00
Rizumu Ayaka
8c93567019 perf: detect pointer drag in useTransformSettling for pan optimization (#9649)
## Summary

When many nodes are rendered in the transform container, both zoom and
pan can cause FPS drops because the browser re-rasterizes all visible
content at the new transform. `will-change: transform` tells the browser
to keep the layer as a GPU texture and skip re-rasterization during
active interaction, restoring visual quality only after settling.

- Add pointer drag detection so `will-change: transform` covers pan in
addition to zoom. Without this, dragging with 256+ nodes causes jank as
the browser re-rasterizes the entire layer on every frame of the pan.
- Fix settleDelay from 16ms to 256ms. At 16ms the debounce fires between
consecutive wheel events (~50ms apart on a physical mouse), causing
`will-change` to toggle on/off rapidly. Each toggle forces the browser
to promote/demote the compositor layer, which is more expensive than not
having the optimization at all.
- Replace scoped CSS with Tailwind `will-change-transform`.
- Remove per-node `will-change: transform` on `.lg-node`. Promoting each
node to its own compositor layer (256 nodes = 256 GPU textures)
increases memory pressure and compositing overhead, making performance
worse than a single promoted container.
- Previously, the virtual DOM of Nodes was updated during zooming and
dragging, but now this update is avoided through some techniques.
- Using the 3D versions of scale and translate can provide a smoother
experience when dealing with a large number of nodes.

## Test plan
- [x] Unit tests updated and passing
- [x] Manual: verify during both zoom and pan
- [x] Manual: compare pan FPS with 256 nodes before/after

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9649-perf-detect-pointer-drag-in-useTransformSettling-for-pan-optimization-31e6d73d3650818bb2c3ccd01a465140)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-12 13:30:18 -07:00
30 changed files with 872 additions and 148 deletions

View File

@@ -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`, currently v24) and pnpm
- Node.js (see `.nvmrc` for the required version) and pnpm
- Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
@@ -87,6 +87,10 @@ 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

368
TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,368 @@
# 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

View File

@@ -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 from `.nvmrc` installed (currently v24).
Ensure you have the Node.js version specified in `.nvmrc` installed.
Then, set up the Chromium test driver:
```bash

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1420,15 +1420,6 @@ 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 {

View File

@@ -81,7 +81,6 @@
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
/>
</TransformPane>

View File

@@ -59,7 +59,10 @@ function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
}
const pathPlusQueryParams = api.apiURL(
'/view?' + params.toString() + app.getPreviewFormatParam()
'/view?' +
params.toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
const imageElement = new Image()
imageElement.crossOrigin = 'anonymous'

View File

@@ -17,7 +17,7 @@ type MockTask = {
executionEndTimestamp?: number
previewOutput?: {
isImage: boolean
url: string
urlWithTimestamp: string
}
}
@@ -94,7 +94,7 @@ describe(useQueueNotificationBanners, () => {
if (previewUrl) {
task.previewOutput = {
isImage,
url: previewUrl
urlWithTimestamp: previewUrl
}
}

View File

@@ -231,7 +231,7 @@ export const useQueueNotificationBanners = () => {
completedCount++
const preview = task.previewOutput
if (preview?.isImage) {
imagePreviews.push(preview.url)
imagePreviews.push(preview.urlWithTimestamp)
}
} else if (state === 'failed') {
failedCount++

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
@@ -57,6 +58,11 @@ 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(
@@ -72,6 +78,11 @@ 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[] {
@@ -304,4 +315,9 @@ 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'
})
}

View File

@@ -1,6 +1,7 @@
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<{
@@ -23,10 +24,11 @@ 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}`)
return api.apiURL(`/view?${params}${rand}`)
}
const beforeImages =

View File

@@ -2,6 +2,7 @@ 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(
@@ -132,7 +133,8 @@ class Load3dUtils {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -119,17 +119,31 @@ vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
getAssetType: mockGetAssetType
}))
const mockGetOutputAssetMetadata = vi.hoisted(() =>
vi.fn().mockReturnValue(null)
)
vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
getOutputAssetMetadata: mockGetOutputAssetMetadata
}))
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
deleteAsset: mockDeleteAsset,
createAssetExport: mockCreateAssetExport
}
}))
const mockTrackExport = vi.hoisted(() => vi.fn())
vi.mock('@/stores/assetExportStore', () => ({
useAssetExportStore: () => ({
trackExport: mockTrackExport
})
}))
vi.mock('@/scripts/api', () => ({
api: {
deleteItem: vi.fn(),
@@ -259,6 +273,106 @@ 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

View File

@@ -146,7 +146,10 @@ export function useMediaAssetActions() {
if (!jobIds.includes(jobId)) {
jobIds.push(jobId)
}
if (metadata?.jobId && asset.name) {
// 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 (!jobAssetNameFilters[metadata.jobId]) {
jobAssetNameFilters[metadata.jobId] = []
}

View File

@@ -50,6 +50,12 @@ 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(

View File

@@ -2,6 +2,7 @@ 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'
@@ -119,6 +120,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
useCurrentUser().onUserResolved((user) => {
if (this.posthog && user.id) {
this.posthog.identify(user.id)
this.setSubscriptionProperties()
}
})
})
@@ -211,6 +213,19 @@ 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)
}

View File

@@ -133,21 +133,6 @@ 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 () => {
@@ -166,43 +151,10 @@ 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, {

View File

@@ -1,13 +1,8 @@
<template>
<div
ref="transformPaneRef"
data-testid="transform-pane"
:class="
cn(
'pointer-events-none absolute inset-0 size-full',
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
)
"
:style="transformStyle"
class="pointer-events-none absolute inset-0 size-full will-change-auto"
>
<!-- Vue nodes will be rendered here -->
<slot />
@@ -16,12 +11,11 @@
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import { computed } from 'vue'
import { computed, useTemplateRef, watch } 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
@@ -33,7 +27,32 @@ const { transformStyle, syncWithCanvas } = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 16
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)
}
})
useRafFn(
@@ -46,9 +65,3 @@ useRafFn(
{ immediate: true }
)
</script>
<style scoped>
.transform-pane--interacting {
will-change: transform;
}
</style>

View File

@@ -69,11 +69,40 @@ describe('useTransformSettling', () => {
expect(isTransforming.value).toBe(false)
})
it('should not track pan events', async () => {
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 () => {
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()

View File

@@ -5,7 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
interface TransformSettlingOptions {
/**
* Delay in ms before transform is considered "settled" after last interaction
* @default 200
* @default 256
*/
settleDelay?: number
/**
@@ -16,10 +16,10 @@ interface TransformSettlingOptions {
}
/**
* Tracks when canvas zoom transforms are actively changing vs settled.
* Tracks when canvas transforms (zoom or pan) are actively changing vs settled.
*
* This composable helps optimize rendering quality during zoom transformations.
* When the user is actively zooming, we can reduce rendering quality
* This composable helps optimize rendering quality during transform interactions.
* When the user is actively zooming or panning, we can reduce rendering quality
* for better performance. Once the transform "settles" (stops changing), we can
* trigger high-quality re-rasterization.
*
@@ -50,35 +50,72 @@ 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)
/**
* Handle zoom transform event - mark active then queue settle
*/
const handleWheel = () => {
markTransformActive()
function markInteracting() {
isTransforming.value = true
void markTransformSettled()
}
// Register wheel event listener with auto-cleanup
useEventListener(target, 'wheel', handleWheel, {
capture: true,
passive
})
const eventOptions = { capture: true, passive }
useEventListener(target, 'wheel', markInteracting, eventOptions)
usePointerDrag(target, markInteracting, eventOptions)
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
)
}

View File

@@ -47,7 +47,7 @@ describe('useTransformState', () => {
it('should generate correct initial transform style', () => {
const { transformStyle } = transformState
expect(transformStyle.value).toEqual({
transform: 'scale(1) translate(0px, 0px)',
transform: 'scale3d(1, 1, 1) translate3d(0px, 0px, 0)',
transformOrigin: '0 0'
})
})
@@ -102,7 +102,7 @@ describe('useTransformState', () => {
syncWithCanvas(mockCanvas as LGraphCanvas)
expect(transformStyle.value).toEqual({
transform: 'scale(0.5) translate(150px, 75px)',
transform: 'scale3d(0.5, 0.5, 0.5) translate3d(150px, 75px, 0)',
transformOrigin: '0 0'
})
})

View File

@@ -79,7 +79,9 @@ 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
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
// 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)`,
transformOrigin: '0 0'
}))

View File

@@ -7,6 +7,7 @@ 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
@@ -33,9 +34,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
@@ -68,7 +69,8 @@ const modelValue = defineModel<string | undefined>({
const { t } = useI18n()
const toastStore = useToastStore()
const queueStore = useQueueStore()
const outputMediaAssets = useMediaAssets('output')
const transformCompatProps = useTransformCompatOverlayProps()
@@ -147,36 +149,28 @@ 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', 'mesh'].includes(props.assetKind ?? '')) return []
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
return []
const outputs = new Set<string>()
const targetMediaType = assetKindToMediaType(props.assetKind!)
const outputFiles = outputMediaAssets.media.value.filter(
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
)
// 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 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)
}
})
return Array.from(outputs).map((output) => ({
id: `output-${output}`,
preview_url: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
label: getDisplayLabel(output)
}))
})
/**
@@ -465,11 +459,18 @@ function getMediaUrl(
filename: string,
type: 'input' | 'output' = 'input'
): string {
if (!['image', 'video'].includes(props.assetKind ?? '')) return ''
if (!['image', 'video', 'audio', 'mesh'].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>
@@ -495,6 +496,7 @@ function getMediaUrl(
class="w-full"
@update:selected="updateSelectedItems"
@update:files="handleFilesUpdate"
@update:is-open="handleIsOpenUpdate"
/>
</WidgetLayoutField>
</template>

View File

@@ -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

View File

@@ -1,4 +1,5 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
@@ -19,7 +20,8 @@ export function getResourceURL(
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -382,6 +382,11 @@ 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)

View File

@@ -136,6 +136,140 @@ 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>

View File

@@ -73,6 +73,24 @@ 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
@@ -112,7 +130,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 = executionIdToNodeLocatorId(app.rootGraph, executionId)
const locatorId = cachedExecutionIdToLocator(executionId)
if (!locatorId) continue
result[locatorId] = mergeExecutionProgressStates(
@@ -220,6 +238,7 @@ 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: {} }
@@ -437,6 +456,7 @@ 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) {

View File

@@ -117,11 +117,12 @@ 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}`)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}

View File

@@ -104,6 +104,10 @@ 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
}