Compare commits

...

14 Commits

Author SHA1 Message Date
Talmaj Marinc
842e3d7541 Initial commit for DynamiGroupSupport. 2026-06-25 00:14:28 +02:00
AustinMroz
c406042215 More robust drag cleanup (#13084)
Under some circumstances, (particularly with pointerCancel events) a
drag operation could end without properly being cleaned up. When this
occurs, the bugged state would manifest in comical ways
- Nodes would 'run away' from the cursor
<img width="1024" height="1024" alt="AnimateDiff_00001"
src="https://github.com/user-attachments/assets/accfeac0-ce4c-4d8a-b3b8-6b243e8d5f8d"
/>

- Resizing the window could cause the zombie drag to move into the
autopan region which would result in nodes rapidly scrolling away.
<img width="1024" height="1024" alt="AnimateDiff_00002"
src="https://github.com/user-attachments/assets/e30629f4-ddea-4981-83d8-0037b3010ad5"
/>


This is resolved by adding more robust cleanup for canceled drag events.

This PR also cleanups a sizeable chunk of dead TransformPane code which
was unused.
2026-06-24 17:49:30 +00:00
pythongosssss
395b0a1c89 fix: prevent NullGraphError on subgraph node removal (#11804)
## Summary

Various race conditions can cause `NullGraphError` to be thrown after
removing/converting a subgraph. This fix guards at call sites and
refactors to add a pre-removal phase before the graph is nulled.

## Changes

- **What**: 
- add pre-detach event (node:before-removed) so reactive consumers can
drop references before node.graph is nulled
- move selection and Vue node-manager teardown to this event to
eliminate stale panel/render evaluations against detached nodes
- guard SubgraphNode promoted-widget paths resilient on detached access
and add regression coverage
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus
Alternative considered approach:
- Guards: Guards were treating the symptom at every caller, and new
callers may appear that won't know about this edge case. Adding a new
hook for consumers to drop refs is safer than trying to guard every call
site - the ones that are left in are safetynets and not the primary fix.
- Large scale refactor (towards ADR0008) - requires additional
scaffolding to already be in place to implement effectively, this fix
simply adds a new hook and isnt incompatible with the projects future
goals
- Defer/remove/reorder graph null - The detach was explicitly added in
#8180 to ensure GC - delaying is fragile and may not resolve the issue,
difficult to prove and may surface a new race condition
- Make rootGraph nullable - would require 100s of references to be
updated, when `NullGraphError` was added in #8180 to throw a clear
message when the graph for a removed subgraph node was referenced,
potentially leading to other harder to track bugs without the exception

Tests:

- e2e test complexity is required to prove the issue happens, patching
calls to add artificial delays. This isn't great, but I could not find a
reliable way to recreate otherwise, unless we are happy to drop e2e and
keep only unit tests.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11804-fix-prevent-NullGraphError-on-subgraph-node-removal-3536d73d3650814e9183e17067cc0992)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-06-24 14:50:02 +00:00
Alexander Brown
6068571b35 Refactor: Brand node execution and locator IDs (#13071)
## Summary

- Brand `NodeExecutionId` and `NodeLocatorId` as distinct required
string types.
- Route execution/locator ID construction through existing helper
functions instead of minting raw strings at call sites.
- Update tests and boundary parsing to use branded IDs without
conflating them with local `NodeId` values.

## Validation

- `pnpm typecheck`
- `pnpm test:unit src/types/nodeIdentification.test.ts
src/stores/executionStore.test.ts
src/renderer/extensions/vueNodes/components/NodeSlots.test.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/platform/nodeReplacement/missingNodeScan.test.ts -- --runInBand`
- `pnpm exec eslint src/types/nodeIdentification.ts
src/utils/graphTraversalUtil.ts
src/platform/workflow/management/stores/workflowStore.ts
src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts
src/renderer/extensions/vueNodes/execution/useNodeExecutionState.ts
src/stores/workspace/favoritedWidgetsStore.ts
src/stores/nodeOutputStore.ts
src/utils/__tests__/executionErrorTestUtils.ts
src/platform/nodeReplacement/missingNodeScan.test.ts
src/stores/executionStore.test.ts --cache`

Note: full `pnpm lint` timed out after 5 minutes while still in
stylelint startup, so targeted lint was run on changed files.

## Open Question

- Should root-level node IDs like `1` be considered valid
`NodeExecutionId` values, or should `isNodeExecutionId()` require a
colon and callers use a separate type/helper for root execution IDs?
2026-06-24 14:35:57 +00:00
Alexander Brown
e37f168eaa Add merge_group event to CLA workflow (#13093)
## Summary

So it runs in the merge queue.
2026-06-24 08:34:29 -07:00
Rizumu Ayaka
b165b3f999 fix: focus keybindings search when opening Manage Shortcuts (FE-845) (#12709)
## Summary

Opening the Keybinding panel from the **Manage Shortcuts** button now
focuses the **Search Keybindings** field instead of the **Search
Settings** field.

## Changes

- **What**: The Settings dialog's "Search Settings" input had an
unconditional `autofocus`, so opening directly to the keybinding panel
always stole focus to the wrong field. Made it conditional
(`:autofocus="activeCategoryKey !== 'keybinding'"`) and added
`autofocus` to the keybinding panel's own search input.

## Review Focus

- `autofocus` maps to the native attribute, which only fires on DOM
insertion — flipping the reactive `:autofocus` while navigating between
categories inside the dialog will not re-steal focus, so there is no
regression for in-dialog navigation.
- Added an E2E test verified in both directions: it fails on the
original code (Search Settings focused) and passes with the fix (Search
Keybindings focused).

Fixes FE-845

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 11:05:01 +00:00
Terry Jia
d7f9754393 feat: add bounding boxes and colors widgets (CORE-292) (#12960)
## Summary
Add two reusable node widgets backed by native (non-string) values:
- Bounding boxes editor (BOUNDING_BOXES): draw, select, resize, and
label regions over an optional background image. Value is a native list
of `{ x, y, width, height, metadata }` pixel boxes; the editor works in
normalized space internally and converts at the value boundary,
rescaling when the node's width/height change.
- Colors palette (COLORS): native `string[]` of hex colors, sharing the
PaletteSwatchRow component (usePaletteSwatchRow composable).

Both reactively hide the width/height widgets while a background image
is connected by writing through the widget value store so the Vue node
re-renders.

Some design refer to KJ's node

BE: https://github.com/Comfy-Org/ComfyUI/pull/14537

Screenshot
<img width="3019" height="1470" alt="image"
src="https://github.com/user-attachments/assets/06795772-97e6-4084-9205-e370f955fb28"
/>

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 11:00:18 +00:00
Terry Jia
48a3ea0e92 feat: add HDR/EXR image viewer for SaveImageAdvanced outputs (#13049)
## Summary
Browsers cannot render EXR/HDR in <img>, so these outputs showed as
broken images. Add a full-screen three.js viewer holding a single WebGL
context created on open and released on close, opened via an 'Open in
HDR Viewer' action on EXR/HDR outputs in ImagePreview. The layout
mirrors the 3D viewer: canvas on the left, grouped controls in a
right-hand sidebar.

The display pipeline (gamut -> exposure -> linear-to-sRGB -> dither ->
clamp, plus clip warnings) is adapted from
[HDRView](https://github.com/wkjarosz/hdrview). Source gamut is
auto-detected from the EXR chromaticities attribute (Rec.709/Rec.2020)
with a manual override.

Inspection tools operate on the EXR float data kept on the CPU by
EXRLoader:
- pixel inspector: hover to read raw RGBA values and coordinates
- statistics: min/max/mean/std-dev plus NaN and Inf counts
- auto-exposure: set exposure so the max value maps to 1
- channel isolation: view R/G/B/A or luminance individually

## Screenshots (if applicable)



https://github.com/user-attachments/assets/22b80718-4b15-41ee-86b5-8fe38a6a82e2
2026-06-24 06:43:30 -04:00
Rizumu Ayaka
a8f8ba7580 fix: clamp sidebar tab labels to two lines with tooltip fallback (#12755)
## Summary

Sidebar tab labels no longer overflow the rail: they wrap up to 2 lines
max, then truncate with an ellipsis, with the full name always
recoverable via the hover tooltip (per design spec from Alex Tov in
FE-698).

## Changes

- **What**:
- Labels in `SidebarIcon.vue` now use `line-clamp-2` + `overflow-wrap:
break-word` + `whitespace-normal`, contained within the rail width minus
`--sidebar-padding` so text keeps breathing room from the rail border
(the base Button's `whitespace-nowrap` previously prevented any
wrapping, causing labels like "Input & Output" to be clipped on both
sides)
- Near-fit built-in labels ("Workflows", "Templates", "Shortcuts" —
wider than the floating-mode line) get soft hyphens (`­`) in their en
label strings, so they break cleanly as "Work-/flows" in floating mode
and render as a single unhyphenated line in connected mode (56px).
`hyphens: auto` can't do this because Chromium skips hyphenation for
capitalized words. Title/tooltip strings are untouched
- Tooltip falls back to the label when an extension registers a sidebar
tab without a tooltip, so clamped text is always recoverable on hover

## Review Focus

- Labels never bleed past the rail or get clipped by the rail's
`overflow-hidden`; long unbroken extension names (e.g.
`WASNodeSuitePreprocessors`) break mid-token across 2 lines + ellipsis,
matching the design mockup
- Soft hyphens live only in `sideToolbar.labels.*`, not in the
title/tooltip keys, so command palette / tooltip text stays clean
- No E2E regression test: the fix is pure CSS layout (line
wrapping/clamping), and per `AGENTS.md` testing guidelines we don't
write tests that depend on non-behavioral styling. The one behavioral
change (tooltip falls back to label) is covered by a unit test in
`SidebarIcon.test.ts`

Fixes
[FE-698](https://linear.app/comfyorg/issue/FE-698/bug-input-and-outputs-text-not-wrapping-in-left-sidebar)

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-24 10:19:23 +00:00
jaeone94
966659b303 fix: bind promoted asset modals (Legacy) to host widgets (#13075)
## Summary

Bind asset-browser modal selections to the widget that actually opened
the modal, so promoted subgraph asset widgets commit through the host
promoted widget instead of the internal source widget closure.

## Changes

- **What**: Makes the asset-browser modal commit path widget-owned:
after a valid selection, `openModal` writes to the widget passed into
the modal and notifies that widget's callback.
- **What**: Captures workflow state after a successful value-changing
asset selection, because the async modal `Use` action can run after the
global mouseup-based change capture has already fired.
- **What**: Preserves existing asset-browser filtering by keeping
`nodeTypeForBrowser` and `inputNameForBrowser` captured in the asset
widget's existing modal options closure.
- **What**: Avoids adding promoted-widget-specific rebinding code to
`litegraphService` and avoids changing LiteGraph core widget option
types.
- **What**: Only runs the source widget's `onValueChange` callback when
the selected widget is the original owner widget created by
`createAssetWidget`.
- **What**: For cloned/transient host widgets, such as promoted subgraph
asset widgets, dispatches `onWidgetChanged` through the widget's owning
node instead of the internal source node.
- **What**: Removes the duplicate PrimitiveNode callback dispatch
because the asset modal commit path now centrally notifies the selected
widget callback.
- **What**: Adds stable asset-browser `data-testid`s and a cloud E2E
regression for legacy promoted subgraph asset selection.
- **What**: Adds unit coverage for both regular asset widget commits and
cloned promoted-host asset modal commits, including workflow change
capture.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

This PR supersedes #13074. The earlier direction treated the bug as a
missing callback bridge in the async asset-browser commit path, but the
ownership issue is more specific: promoted subgraph asset widgets reuse
modal options that were created from the deepest concrete source widget.
Those options still need to carry source metadata for filtering the
asset browser, but the modal's `Use` action must commit to the widget
that actually opened the modal.

This matters after the History ADR 0009 subgraph widget changes shipped
through #12197. In the 1.46 subgraph model, promoted widget values live
on the subgraph host node and are not synchronized back into the
internal widget. The internal source widget remains useful as the
provider of asset-browser metadata, because `SubgraphNode` already
resolves nested promotions down to the final concrete widget, but it
should not own the edit commit.

The final patch keeps that boundary narrow:

- no `IWidgetOptions` or LiteGraph core type changes;
- no asset-specific promoted-widget rebinding in `litegraphService`;
- no new promoted-widget traversal logic, because the existing subgraph
promotion path already resolves the final concrete source widget;
- the modal commit path uses the widget passed to `openModal` as the
value owner;
- successful async modal commits explicitly capture workflow state when
the selected value changes.

Please focus review on whether `createAssetWidget` now preserves regular
asset widget behavior while correctly handling cloned/transient host
widgets. The key distinction is that the source `onValueChange` path
only runs for the original owner widget; promoted host wrappers instead
rely on their callback bridge and owning node's `onWidgetChanged` hook.

A review pass also found that this PR makes an existing async modal
weakness more visible: asset-browser selection happens from the modal
button's `click` handler, while the global change tracker also captures
on `mouseup`. Depending on event ordering, the automatic capture can
occur before the selection mutates the widget. This PR now captures
workflow state immediately after a successful value-changing asset
selection so undo/modified tracking follows the same user-visible edit.

Local verification:

- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --reporter=dot`
- `pnpm exec vitest run
src/platform/assets/utils/createAssetWidget.test.ts --coverage
--reporter=dot --coverage.reporter=text
--coverage.include=src/platform/assets/utils/createAssetWidget.ts`
- `pnpm exec eslint src/platform/assets/utils/createAssetWidget.ts
src/platform/assets/utils/createAssetWidget.test.ts`
- `pnpm typecheck`
- `pnpm format:check`
- `pnpm build:cloud`

---------

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-24 10:16:43 +00:00
Alexis Rolland
a95dab2f59 Update allowlist in CLA workflow (#13091)
Update `allowlist` in CLA workflow to add
[actions-user](https://github.com/actions-user)
2026-06-24 10:14:16 +00:00
Alexis Rolland
5f90bacb73 ci: add CLA Assistant workflow (#13058)
Adds the CLA Assistant GitHub Actions workflow at
`.github/workflows/cla.yml`, copied from
https://github.com/Comfy-Org/comfy-cla/blob/main/.github/workflows/cla.yml

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-24 06:53:53 +00:00
ShihChi Huang
84319bea13 refactor: drop redundant isCloud guards around telemetry calls (#13082)
## Summary

Remove redundant `if (isCloud)` guards around `useTelemetry()?.x()`
calls. `useTelemetry()` already returns `null` in OSS builds, so the
optional-chain calls no-op there — the guards only duplicated that
central contract.

## Changes

- **What**: Drop the `isCloud` guard wrapping telemetry calls across 9
files and remove the 5 now-unused `isCloud` imports (pure dedent —
implementations unchanged). Add two-path (cloud + OSS) characterization
tests for the two previously-uncovered composables
(`useTemplateWorkflows`, `useSubscriptionActions`).

## e2e
In local/OSS mode, useTelemetry() returns null, so no telemetry-related
behavior occurs, and the workflow loads as expected. There are no
local/OSS flow regressions for the exact template workflow paths touched
by the branch.

| before | after |
| -- | -- |
| <img width="1280" height="800" alt="before-01-templates-open"
src="https://github.com/user-attachments/assets/1cccc686-4e3a-4cf0-a578-a653a1383e3c"
/> | <img width="1280" height="800" alt="after-01-templates-open"
src="https://github.com/user-attachments/assets/ff834a58-4375-432a-8cc1-6e04ceeece77"
/> |
| <img width="1280" height="800" alt="before-02-template-loaded"
src="https://github.com/user-attachments/assets/1abd301b-d66d-4819-a0f3-9dff1a1e23b5"
/> | <img width="1280" height="800" alt="after-02-template-loaded"
src="https://github.com/user-attachments/assets/9fbb6903-c085-4744-b683-39b01680c654"
/> |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Behavior is intended to be unchanged: OSS still no-ops via null
telemetry. Router page-view tracking may run in more build contexts but
remains guarded by optional chaining.
> 
> **Overview**
> Removes duplicate **`if (isCloud)`** wrappers around
**`useTelemetry()?.…()`** across onboarding, auth, templates,
subscription UI, and routing. Call sites now rely on
**`useTelemetry()`** returning **`null`** in OSS (optional chaining
stays a no-op there), and several unused **`isCloud`** imports are
dropped.
> 
> **`trackPageView`** in the router no longer bails early on cloud-only
or **`window`** checks; it always invokes
**`useTelemetry()?.trackPageView(...)`** on navigation.
> 
> Adds characterization tests for **`useTemplateWorkflows`** and
**`useSubscriptionActions`** that assert telemetry fires when the mock
dispatcher is registered and does not when the mock simulates OSS
(**`useTelemetry()` → null**).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fd6c9a56bd. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: ShihChi Huang <shh@theonlyperson.com>
2026-06-24 06:06:19 +00:00
Simon Pinfold
f076106ca5 fix: expand grouped assets when downloading multi-selection on OSS backend (#13079)
*PR Created by the Glary-Bot Agent*

---

## Summary

When using the ComfyUI (non-cloud) backend, selecting a grouped asset
(`outputCount > 1`) and clicking Download only downloaded the
cover/preview image instead of every output in the group.

The cloud backend already handles this correctly by exporting a ZIP
server-side. The OSS path called `downloadFile(asset.preview_url, ...)`
once per selected `AssetItem` and never expanded grouped assets into
their individual outputs.

## Fix

In `useMediaAssetActions.downloadAssets`, when any selected asset has
`outputCount > 1` and we're on the OSS path, resolve each grouped asset
into its individual outputs via the existing `resolveOutputAssetItems`
utility and trigger one direct download per file. Non-grouped selections
keep the original single-shot behaviour. After expansion the file list
is deduplicated by `AssetItem.id` so a user who selects both an expanded
stack parent and one of its children does not download the child twice.
The success toast now reflects the actual number of files downloaded.

- Single asset, single output → unchanged (1 download).
- Multi-select of single-output assets → unchanged (N downloads).
- Any selection containing a grouped asset → expanded via
`resolveOutputAssetItems` (same code path the cloud ZIP and
stack-expansion UI use). If resolution returns nothing, falls back to
the preview download so the user still gets something.
- Grouped parent + one of its expanded children selected → deduped, no
double download.

## Tests

Added unit tests in `useMediaAssetActions.test.ts` for the OSS path:

- Expands a grouped asset into individual downloads.
- Mixes grouped and single-output assets in one selection.
- Falls back to the original asset when `resolveOutputAssetItems`
returns empty.
- Does not call `resolveOutputAssetItems` when no grouped assets are
selected.
- Deduplicates downloads when an expanded child is also selected
alongside its parent.
- Shows an error toast when resolution rejects.

All 40 tests in the file pass; all 508 tests under `src/platform/assets`
pass. `pnpm typecheck`, `pnpm exec eslint`, `pnpm exec oxfmt --check`
all clean.

## Manual verification
Tested against a `master` ComfyUI instance with default settings.
Not tested against cloud - feature is gated to non cloud

Performed by @synap5e 
<img width="448" height="618" alt="image"
src="https://github.com/user-attachments/assets/daf32fa0-c5ec-47ca-bab3-d5ea3fb3d7cc"
/>


https://github.com/user-attachments/assets/a87ae1aa-836f-4cbc-9ef7-a35ed4f94ee7


https://github.com/user-attachments/assets/49d833bf-7b4e-4c53-b0d5-f16ff2108185

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-24 02:25:02 +00:00
154 changed files with 7255 additions and 1041 deletions

63
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

View File

@@ -56,12 +56,16 @@ class ComfyPropertiesPanel {
readonly panelTitle: Locator
readonly searchBox: Locator
readonly titleEditor: TitleEditor
readonly toggleButton: Locator
constructor(readonly page: Page) {
this.root = page.getByTestId(TestIds.propertiesPanel.root)
this.panelTitle = this.root.locator('h3')
this.searchBox = this.root.getByPlaceholder(/^Search/)
this.titleEditor = new TitleEditor(this.root)
this.toggleButton = page.getByRole('button', {
name: 'Toggle properties panel'
})
}
}

View File

@@ -112,6 +112,10 @@ export const TestIds = {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
assets: {
browserModal: 'asset-browser-modal',
card: 'asset-card'
},
subgraphEditor: {
hiddenSection: 'subgraph-editor-hidden-section',
iconEye: 'icon-eye',

View File

@@ -223,4 +223,23 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
test('should focus keybindings search when opening manage shortcuts', async ({
comfyPage
}) => {
const { bottomPanel } = comfyPage
await bottomPanel.keyboardShortcutsButton.click()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
await expect(
comfyPage.page.getByPlaceholder('Search Keybindings...')
).toBeFocused()
await expect(
comfyPage.page.getByPlaceholder('Search Settings...')
).not.toBeFocused()
})
})

View File

@@ -0,0 +1,99 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
const WORKFLOW = 'missing/missing_model_promoted_widget'
const HOST_NODE_ID = 2
const WIDGET_NAME = 'ckpt_name'
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
interface WidgetSnapshot {
type: string
value: string
hasLayout: boolean
}
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
return await page.evaluate(
({ nodeId, widgetName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
return {
type: widget?.type ?? '',
value: String(widget?.value ?? ''),
hasLayout: widget?.last_y != null
}
},
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
)
}
test.describe(
'Promoted subgraph asset widgets',
{ tag: ['@cloud', '@canvas', '@widget'] },
() => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('legacy asset browser selection updates the promoted host widget value', async ({
cloudAssetRequests,
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await expect
.poll(
() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'checkpoints')
),
{ timeout: 10_000 }
)
.toBe(true)
await expect
.poll(() => getHostWidgetSnapshot(comfyPage.page))
.toMatchObject({
type: 'asset',
hasLayout: true
})
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
await hostNode.centerOnNode()
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
await promotedWidget.click()
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
await expect(modal).toBeVisible()
const assetCard = modal
.getByTestId(TestIds.assets.card)
.filter({ hasText: SELECTED_MODEL })
.first()
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use' }).click()
await expect(modal).toBeHidden()
await expect
.poll(() =>
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
)
.toBe(SELECTED_MODEL)
})
}
)

View File

@@ -1,6 +1,9 @@
import type { ConsoleMessage } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
@@ -95,4 +98,225 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
})
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
// widening the race window so a guard regression deterministically surfaces.
async function deferLegacyHandlers(comfyPage: ComfyPage) {
return await comfyPage.page.evaluateHandle(() => {
const graph = window.app!.graph!
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const originalNodeRemoved = graph.onNodeRemoved
const originalSelectionChange = canvas.onSelectionChange
graph.onNodeRemoved = function (node) {
queue.push(() => originalNodeRemoved?.call(this, node))
}
canvas.onSelectionChange = function (selected) {
queue.push(() => originalSelectionChange?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
graph.onNodeRemoved = originalNodeRemoved
canvas.onSelectionChange = originalSelectionChange
}
}
})
}
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
// Defers only the legacy selection-change callback, so the detached host
// node lingers in the reactive selection while onNodeRemoved still runs
// normally and clears it from the canvas. This isolates the panel render
// path: a panel mounted during this window reads the stale selection.
async function deferSelectionChange(
comfyPage: ComfyPage
): Promise<DeferredHandlers> {
return await comfyPage.page.evaluateHandle(() => {
const canvas = window.app!.canvas!
const queue: Array<() => void> = []
const original = canvas.onSelectionChange
canvas.onSelectionChange = function (selected) {
queue.push(() => original?.call(this, selected))
}
return {
drain: () => {
for (const fn of queue.splice(0)) fn()
},
restore: () => {
canvas.onSelectionChange = original
}
}
})
}
function isNullGraphErrorText(text: string): boolean {
return text.includes('NullGraphError') || text.endsWith('has no graph')
}
// Vue's default errorHandler routes render throws to console.error,
// not pageerror - listen to both.
function captureNullGraphErrors(comfyPage: ComfyPage) {
const captured: string[] = []
const onPageError = (err: Error) => {
if (
err.name === 'NullGraphError' ||
isNullGraphErrorText(err.message ?? '')
) {
captured.push(`pageerror ${err.name}: ${err.message}`)
}
}
const onConsoleMessage = (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (isNullGraphErrorText(text)) {
captured.push(`console.error: ${text}`)
}
}
comfyPage.page.on('pageerror', onPageError)
comfyPage.page.on('console', onConsoleMessage)
return {
getErrors: () => [...captured],
stop: () => {
comfyPage.page.off('pageerror', onPageError)
comfyPage.page.off('console', onConsoleMessage)
}
}
}
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await comfyPage.contextMenu.openForVueNode(fixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
}
async function reopenRightSidePanel(comfyPage: ComfyPage) {
const { propertiesPanel } = comfyPage.menu
await propertiesPanel.toggleButton.click()
await expect(propertiesPanel.root).toBeHidden()
await propertiesPanel.toggleButton.click()
await comfyPage.nextFrame()
}
// Unpacks the subgraph behind deferred teardown, runs an optional
// interaction while the node is detached but not yet cleaned up, then
// drains the deferred handlers and reports any NullGraphErrors seen.
async function unpackAndCaptureNullGraphErrors(
comfyPage: ComfyPage,
options: {
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
}
): Promise<string[]> {
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
const errors = captureNullGraphErrors(comfyPage)
const deferred = await options.defer(comfyPage)
try {
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toHaveCount(0)
await options.duringWindow?.(comfyPage)
await deferred.evaluate((handlers) => handlers.drain())
// Let drained-handler reactive flushes settle before stop().
await comfyPage.nextFrame()
return errors.getErrors()
} finally {
await deferred.evaluate((handlers) => handlers.restore())
await deferred.dispose()
errors.stop()
}
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode =
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
await expect(subgraphNode).toBeVisible()
const fixture =
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
await fixture.header.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await comfyPage.nextFrame()
})
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'LGraphNode render path: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferLegacyHandlers
})
expect(
nullGraphErrors,
'SubgraphEditor panel: detach race must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
comfyPage
}) => {
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
).toEqual([])
})
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
await comfyPage.nextFrame()
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
defer: deferSelectionChange,
duringWindow: reopenRightSidePanel
})
expect(
nullGraphErrors,
'SubgraphEditor remount: stale selection must not surface NullGraphError'
).toEqual([])
})
})
})

View File

@@ -335,6 +335,30 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
})
test('pointerCancel stops autopan', async ({ comfyPage }) => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.header.click({ trial: true })
await comfyPage.page.mouse.down()
const getOffset = () => comfyPage.canvasOps.getOffset()
const initialOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
await test.step('move outside pan range and cancel drag', async () => {
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
await ksampler.header.evaluate((node) =>
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
)
})
const secondaryOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await comfyPage.nextFrame()
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -344,6 +344,15 @@ export const zDynamicComboInputSpec = z.tuple([
})
])
export const zDynamicGroupInputSpec = z.tuple([
z.literal('COMFY_DYNAMICGROUP_V3'),
zBaseInputOptions.extend({
template: zComfyInputsSpec,
min: z.number().int().nonnegative().optional().default(0),
max: z.number().int().positive().max(100).optional().default(50)
})
])
export const zMatchTypeOptions = z.object({
...zBaseInputOptions.shape,
type: z.literal('COMFY_MATCHTYPE_V3'),

View File

@@ -0,0 +1,224 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
import boundingBoxes from '@/locales/en/main.json'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBoxes: boundingBoxes.boundingBoxes,
palette: { swatchTitle: 'Edit', addColor: 'Add' }
}
}
})
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
const fakeCtx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function prepCanvas(canvas: HTMLCanvasElement) {
Object.defineProperty(canvas, 'clientWidth', {
value: 100,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 100,
configurable: true
})
canvas.getContext = (() =>
fakeCtx) as unknown as HTMLCanvasElement['getContext']
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
canvas.setPointerCapture = () => {}
canvas.releasePointerCapture = () => {}
}
function renderWidget(modelValue: BoundingBox[]) {
const result = render(WidgetBoundingBoxes, {
props: { nodeId: '1', modelValue },
global: { plugins: [i18n] }
})
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
prepCanvas(canvas)
return { ...result, canvas }
}
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0] as BoundingBox[]
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('WidgetBoundingBoxes', () => {
it('renders the canvas and editor shell', () => {
renderWidget([])
expect(
screen.getByTestId('bounding-boxes').querySelector('canvas')
).not.toBeNull()
})
it('shows the region editor panel when a region is active', () => {
renderWidget([box()])
expect(screen.getByText('obj')).toBeTruthy()
expect(screen.getByText('text')).toBeTruthy()
})
it('reveals the text field after switching the region to text', async () => {
renderWidget([box()])
expect(
screen.queryByPlaceholderText('text to render (verbatim)')
).toBeNull()
await userEvent.click(screen.getByText('text'))
expect(
screen.getByPlaceholderText('text to render (verbatim)')
).toBeTruthy()
})
it('clears all regions via the clear button', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('Clear all'))
expect(lastBoxes(emitted)).toEqual([])
})
it('draws a region through canvas pointer events', async () => {
const { canvas, emitted } = renderWidget([])
await fireEvent.pointerDown(canvas, {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 1
})
await fireEvent.pointerMove(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
await fireEvent.pointerUp(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
expect(lastBoxes(emitted)).toHaveLength(1)
})
it('tracks focus and blur on the canvas', async () => {
const { canvas } = renderWidget([box()])
await fireEvent.focus(canvas)
await fireEvent.blur(canvas)
expect(canvas).toBeTruthy()
})
it('opens an inline editor on double click', async () => {
const { canvas, container } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
expect(container.querySelector('textarea')).not.toBeNull()
})
it('syncs description edits back to the model', async () => {
const { emitted } = renderWidget([box()])
await fireEvent.update(
screen.getByPlaceholderText('description of this region'),
'a caption'
)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
})
it('edits the text field once the region is a text region', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('text'))
await fireEvent.update(
screen.getByPlaceholderText('text to render (verbatim)'),
'hello'
)
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
})
it('deletes the active region with the Delete key', async () => {
const { canvas, emitted } = renderWidget([box()])
await fireEvent.keyDown(canvas, { key: 'Delete' })
expect(lastBoxes(emitted)).toEqual([])
})
it('clears hover state on pointer leave', async () => {
const { canvas } = renderWidget([
box({ x: 10, y: 10, width: 256, height: 256 })
])
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
await fireEvent.pointerLeave(canvas)
expect(canvas).toBeTruthy()
})
it('commits the inline editor on blur', async () => {
const { canvas, container, emitted } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
const editor = container.querySelector('textarea')!
await fireEvent.update(editor, 'committed')
await fireEvent.blur(editor)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
})
})

View File

@@ -0,0 +1,181 @@
<template>
<div
class="widget-expands flex size-full flex-col gap-1 select-none"
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
<div
v-if="activeRegion"
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'obj'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('obj')"
>
{{ $t('boundingBoxes.typeObj') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'text'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('text')"
>
{{ $t('boundingBoxes.typeText') }}
</Button>
</div>
<div
v-if="activeRegion.type === 'text'"
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.textLabel') }}
</span>
<Textarea
v-model="activeRegion.text"
:placeholder="$t('boundingBoxes.textPlaceholder')"
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.descLabel') }}
</span>
<Textarea
v-model="activeRegion.desc"
:placeholder="$t('boundingBoxes.descPlaceholder')"
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div class="flex items-center gap-2">
<span class="shrink-0 truncate text-sm text-muted-foreground">
{{ $t('boundingBoxes.colors') }}
</span>
<PaletteSwatchRow
v-model="activeRegion.palette"
:max="maxColors"
@update:model-value="syncState"
/>
</div>
</div>
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { nodeId } = defineProps<{ nodeId: string }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
const {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
</script>

View File

@@ -427,7 +427,6 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
@@ -453,16 +452,14 @@ onMounted(() => {
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
}
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
originalOnClose()
}

View File

@@ -8,6 +8,7 @@
v-model="filters['global'].value"
class="max-w-96"
size="lg"
autofocus
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"

View File

@@ -0,0 +1,126 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HdrViewerContent from './HdrViewerContent.vue'
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
vi.mock('@/composables/useHdrViewer', () => ({
useHdrViewer: () => holder.viewer,
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading', downloadImage: 'Download' },
hdrViewer: {
failedToLoad: 'Failed',
exposure: 'Exposure',
normalizeExposure: 'Auto exposure',
channel: 'Channel',
channels: {
rgb: 'RGB',
r: 'R',
g: 'G',
b: 'B',
a: 'Alpha',
luminance: 'Luminance'
},
sourceGamut: 'Source gamut',
dither: 'Dither',
clipWarnings: 'Clip warnings',
fitView: 'Fit',
histogram: 'Histogram',
resolution: 'Resolution',
min: 'Min',
max: 'Max',
mean: 'Mean',
stdDev: 'Std dev',
nan: 'NaN',
inf: 'Inf'
}
}
}
})
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
exposureStops: ref(0),
dither: ref(true),
clipWarnings: ref(false),
gamut: ref('sRGB'),
channel: ref('r'),
loading: ref(false),
error: ref(null),
dimensions: ref('512 x 512'),
stats: ref({
min: 0,
max: 4,
mean: 0.5,
stdDev: 0.2,
nanCount: 2,
infCount: 1
}),
histogram: ref(new Uint32Array([1, 2, 3, 4])),
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
mount: vi.fn(),
dispose: vi.fn(),
fitView: vi.fn(),
normalizeExposure: vi.fn(),
...overrides
}
}
function renderViewer() {
return render(HdrViewerContent, {
props: { imageUrl: '/api/view?filename=out.exr' },
global: { plugins: [i18n], stubs: { Button: true } }
})
}
describe('HdrViewerContent', () => {
beforeEach(() => {
holder.viewer = makeViewer()
})
it('renders the full statistics set including NaN/Inf', () => {
renderViewer()
for (const label of [
'Resolution',
'Min',
'Max',
'Mean',
'Std dev',
'NaN',
'Inf'
]) {
screen.getByText(label)
}
})
it('shows the pixel readout when a pixel is hovered', () => {
renderViewer()
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
})
it('colors the histogram according to the selected channel', () => {
holder.viewer = makeViewer({ channel: ref('g') })
const { container } = renderViewer()
const path = container.querySelector('svg path')
expect(path?.getAttribute('class')).toContain('text-green-500')
})
it('renders an option for each channel mode', () => {
renderViewer()
expect(
screen.getByRole('option', { name: 'Luminance' })
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,258 @@
<template>
<div class="flex size-full bg-base-background">
<div class="relative flex-1">
<div
ref="containerRef"
class="absolute size-full"
data-testid="hdr-viewer-canvas"
/>
<div
v-if="viewer.loading.value"
class="absolute inset-0 flex items-center justify-center text-base-foreground"
>
{{ $t('g.loading') }}...
</div>
<div
v-else-if="viewer.error.value"
role="alert"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
>
<i class="icon-[lucide--image-off] size-12" />
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
</div>
<div
v-if="viewer.pixel.value"
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
data-testid="hdr-pixel-readout"
>
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
<div>
{{ formatNum(viewer.pixel.value.r) }}
{{ formatNum(viewer.pixel.value.g) }}
{{ formatNum(viewer.pixel.value.b) }}
<template v-if="viewer.pixel.value.a !== null">
{{ formatNum(viewer.pixel.value.a) }}
</template>
</div>
</div>
</div>
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
<input
v-model.number="viewer.exposureStops.value"
type="range"
min="-10"
max="10"
step="0.1"
class="w-full"
:aria-label="$t('hdrViewer.exposure')"
/>
</div>
<Button
variant="secondary"
class="w-full"
@click="viewer.normalizeExposure"
>
{{ $t('hdrViewer.normalizeExposure') }}
</Button>
</div>
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.channel') }}</label>
<select
v-model="viewer.channel.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.channel')"
>
<option v-for="mode in channelModes" :key="mode" :value="mode">
{{ channelLabels[mode] }}
</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
<select
v-model="viewer.gamut.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.sourceGamut')"
>
<option v-for="name in gamutNames" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
</div>
<div class="space-y-4 p-2">
<div class="flex items-center gap-2">
<input
id="hdr-dither"
v-model="viewer.dither.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-dither" class="cursor-pointer">
{{ $t('hdrViewer.dither') }}
</label>
</div>
<div class="flex items-center gap-2">
<input
id="hdr-clip"
v-model="viewer.clipWarnings.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-clip" class="cursor-pointer">
{{ $t('hdrViewer.clipWarnings') }}
</label>
</div>
</div>
<div v-if="histogramPath" class="space-y-2 p-2">
<label>{{ $t('hdrViewer.histogram') }}</label>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
>
<path
:d="histogramPath"
:class="histogramColorClass"
fill="currentColor"
fill-opacity="0.5"
stroke="none"
/>
</svg>
</div>
<div
v-if="viewer.stats.value"
class="space-y-1 p-2 text-xs tabular-nums"
>
<div v-if="viewer.dimensions.value" class="flex justify-between">
<span>{{ $t('hdrViewer.resolution') }}</span>
<span>{{ viewer.dimensions.value }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.min') }}</span>
<span>{{ formatNum(viewer.stats.value.min) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.max') }}</span>
<span>{{ formatNum(viewer.stats.value.max) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.mean') }}</span>
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.stdDev') }}</span>
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
</div>
<div
v-if="viewer.stats.value.nanCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.nan') }}</span>
<span>{{ viewer.stats.value.nanCount }}</span>
</div>
<div
v-if="viewer.stats.value.infCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.inf') }}</span>
<span>{{ viewer.stats.value.infCount }}</span>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
{{ $t('hdrViewer.fitView') }}
</Button>
<Button variant="secondary" class="flex-1" @click="handleDownload">
{{ $t('g.downloadImage') }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import type { ChannelMode } from '@/composables/useHdrViewer'
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
import { histogramToPath } from '@/utils/histogramUtil'
const { imageUrl } = defineProps<{ imageUrl: string }>()
const { t } = useI18n()
const viewer = useHdrViewer()
const gamutNames = GAMUT_NAMES
const channelModes = CHANNEL_MODES
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const exposureLabel = computed(() => {
const value = viewer.exposureStops.value
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
})
const histogramPath = computed(() =>
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
)
const histogramColorClass = computed(() => {
switch (viewer.channel.value) {
case 'r':
return 'text-red-500'
case 'g':
return 'text-green-500'
case 'b':
return 'text-blue-500'
default:
return 'text-base-foreground'
}
})
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
rgb: t('hdrViewer.channels.rgb'),
r: t('hdrViewer.channels.r'),
g: t('hdrViewer.channels.g'),
b: t('hdrViewer.channels.b'),
a: t('hdrViewer.channels.a'),
luminance: t('hdrViewer.channels.luminance')
}))
function formatNum(value: number): string {
if (!Number.isFinite(value)) return String(value)
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
? value.toExponential(3)
: value.toFixed(4)
}
function handleDownload() {
downloadFile(toFullResolutionUrl(imageUrl))
}
onMounted(() => {
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
})
</script>

View File

@@ -0,0 +1,70 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderRow(modelValue: string[], max = 5) {
return render(PaletteSwatchRow, {
props: { modelValue, max },
global: { plugins: [i18n] }
})
}
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0]
}
describe('PaletteSwatchRow', () => {
it('renders one swatch per color', () => {
const { container } = renderRow(['#ff0000', '#00ff00'])
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
it('removes a color on right click', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
expect(lastEmit(emitted)).toEqual(['#00ff00'])
})
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
})
it('starts a drag on pointer down without emitting', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
button: 0,
clientX: 5,
clientY: 5
})
expect(emitted()['update:modelValue']).toBeUndefined()
})
})

View File

@@ -0,0 +1,48 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
<button
v-if="modelValue.length < max"
type="button"
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
:title="t('palette.addColor')"
@click="addColor"
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
</script>

View File

@@ -0,0 +1,54 @@
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetColors from './WidgetColors.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderWidget(modelValue: string[], widget?: { name: string }) {
return render(WidgetColors, {
props: { modelValue, widget },
global: { plugins: [i18n] }
})
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length) cleanups.pop()?.()
})
describe('WidgetColors', () => {
it('renders the palette swatch row for each color', () => {
renderWidget(['#ff0000', '#00ff00'])
const root = screen.getByTestId('colors')
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('shows the widget name as an inline label', () => {
renderWidget(['#ff0000'], { name: 'color_palette' })
expect(screen.getByText('color_palette')).toBeInTheDocument()
})
it('emits an updated palette when a color is added', async () => {
const { emitted } = renderWidget([])
await userEvent.click(screen.getByRole('button'))
const calls = emitted()['update:modelValue'] as unknown[][]
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
})
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
const { container } = renderWidget(['#ff0000'])
const onDocMove = vi.fn()
document.addEventListener('pointermove', onDocMove)
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
expect(onDocMove).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,29 @@
<template>
<div
class="flex size-full items-center gap-2"
data-testid="colors"
@pointerdown.stop
>
<span
v-if="widget?.name"
class="shrink-0 truncate text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</span>
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
</div>
</template>
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const MAX_COLORS = 16
const { widget } = defineProps<{
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
}>()
const modelValue = defineModel<string[]>({ default: () => [] })
</script>

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
@@ -165,7 +166,9 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [1, 2, 3, 4] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'true'
)
@@ -175,7 +178,9 @@ describe('WidgetRange', () => {
outputsHolder.nodeOutputs = {
loc1: { histogram_range_w: [] }
}
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
renderWidget(
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
)
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
'false'
)

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
@@ -23,7 +24,7 @@ type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
@@ -37,7 +38,7 @@ const singleErrorCard: ErrorCardData = {
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: '24',
nodeId: createNodeExecutionId([24]),
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
@@ -55,7 +56,7 @@ const multipleErrorsCard: ErrorCardData = {
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: '45',
nodeId: createNodeExecutionId([45]),
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
@@ -75,7 +76,7 @@ const runtimeErrorCard: ErrorCardData = {
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: '3:15',
nodeId: createNodeExecutionId([3, 15]),
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [

View File

@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
@@ -156,7 +157,7 @@ describe('ErrorNodeCard.vue', () => {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{
@@ -249,7 +250,7 @@ describe('ErrorNodeCard.vue', () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{
@@ -387,7 +388,7 @@ describe('ErrorNodeCard.vue', () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'KSampler',
errors: [
{

View File

@@ -1,4 +1,5 @@
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
export interface ErrorItem extends ResolvedErrorMessage {
/** Raw source/API-compatible message. */
@@ -12,7 +13,7 @@ export interface ErrorItem extends ResolvedErrorMessage {
export interface ErrorCardData {
id: string
title: string
nodeId?: string
nodeId?: NodeExecutionId
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean

View File

@@ -671,6 +671,30 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -39,8 +39,8 @@ import {
resolveRunErrorMessage
} from '@/platform/errorCatalog/errorMessageResolver'
import {
isNodeExecutionId,
compareExecutionId
compareExecutionId,
tryNormalizeNodeExecutionId
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/** Resolve display info for a node by its execution ID. */
function resolveNodeInfo(nodeId: string) {
function resolveNodeInfo(nodeId: NodeExecutionId) {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
return {
@@ -119,7 +119,7 @@ function getOrCreateGroup(
}
function createErrorCard(
nodeId: string,
nodeId: NodeExecutionId,
classType: string,
idPrefix: string
): ErrorCardData {
@@ -130,7 +130,7 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
isSubgraphNode: nodeId.includes(':'),
errors: []
}
}
@@ -288,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -305,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: string,
nodeId: NodeExecutionId,
classType: string,
idPrefix: string,
error: CataloguedErrorItem,
@@ -371,9 +371,11 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
) {
if (!executionErrorStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
for (const e of nodeError.errors) {
@@ -404,9 +406,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
if (!nodeId) return
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
nodeId,
e.node_type,
'exec',
{
@@ -417,8 +422,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
})
},
filterBySelection
@@ -669,7 +673,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function isAssetErrorInSelection(executionNodeId: string): boolean {
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -691,12 +695,17 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return false
}
function isAssetCandidateInSelection(nodeId: string | number): boolean {
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
@@ -707,7 +716,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)

View File

@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ErrorCardData } from './types'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { useErrorReport } from './useErrorReport'
async function flushPromises() {
@@ -103,7 +104,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
return {
id: 'card-1',
title: 'KSampler',
nodeId: '42',
nodeId: createNodeExecutionId([42]),
errors: [],
...overrides
}
@@ -181,7 +182,7 @@ describe('useErrorReport', () => {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: '42',
nodeId: createNodeExecutionId([42]),
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',

View File

@@ -3,17 +3,12 @@ import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'
type SidebarIconProps = {
icon: string
selected: boolean
tooltip?: string
class?: string
iconBadge?: string | (() => string | null)
}
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
const i18n = createI18n({
legacy: false,
@@ -84,4 +79,20 @@ describe('SidebarIcon', () => {
tooltipText
)
})
it('falls back to label for tooltip when no tooltip is provided', async () => {
const labelText = 'WASNodeSuitePreprocessors'
const { user } = renderSidebarIcon({ label: labelText })
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
await user.hover(screen.getByRole('button'))
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
},
{ timeout: 1000 }
)
})
})

View File

@@ -40,9 +40,11 @@
</span>
</div>
</slot>
<!-- w-max sizes the label to the rail instead of the padding-inset
button content box, which is too narrow for one-line labels -->
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-2xs"
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
>{{ st(label, label) }}</span
>
</div>
@@ -83,7 +85,14 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
/**
* Falls back to the label when no tooltip is provided, so labels clamped
* to two lines can always be recovered in full on hover.
*/
const computedTooltip = computed(() => {
const text = tooltip || label
return st(text, text) + tooltipSuffix
})
</script>
<style>

View File

@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { HitMode, Region } from './boundingBoxesUtil'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from './boundingBoxesUtil'
const region = (over: Partial<Region> = {}): Region => ({
x: 0.2,
y: 0.2,
w: 0.2,
h: 0.2,
type: 'obj',
text: '',
desc: '',
palette: [],
...over
})
describe('applyDrag', () => {
it('moves without resizing and keeps width/height', () => {
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
expect(out.x).toBeCloseTo(0.3)
expect(out.y).toBeCloseTo(0.3)
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.2)
})
it('clamps a move so the box stays inside the unit square', () => {
const out = applyDrag(
'move',
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
0.5,
0.5
)
expect(out.x).toBeCloseTo(0.8)
expect(out.y).toBeCloseTo(0.8)
})
it('grows from the bottom-right for draw and resize-br', () => {
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
const out = applyDrag(
mode,
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
0.1,
0.2
)
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.3)
}
})
it('moves the top-left corner on resize-tl', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.1,
0.1
)
expect(out.x).toBeCloseTo(0.6)
expect(out.y).toBeCloseTo(0.6)
expect(out.w).toBeCloseTo(0.1)
expect(out.h).toBeCloseTo(0.1)
})
it('normalizes a corner drag that inverts the box', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.3,
0
)
expect(out.x).toBeCloseTo(0.7)
expect(out.w).toBeCloseTo(0.1)
expect(out.y).toBeCloseTo(0.5)
expect(out.h).toBeCloseTo(0.2)
})
it('resizes single edges', () => {
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
expect(top.y).toBeCloseTo(0.5)
expect(top.h).toBeCloseTo(0.1)
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
expect(left.x).toBeCloseTo(0.5)
expect(left.w).toBeCloseTo(0.1)
})
})
describe('boxesAt', () => {
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
it('detects a corner handle', () => {
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
})
it('detects an interior move', () => {
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
})
it('returns nothing when the pointer misses every box', () => {
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
})
it('brings the active box to the front of overlapping candidates', () => {
const overlapping: Region[] = [
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
]
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
expect(hits).toHaveLength(2)
expect(hits[0].index).toBe(1)
})
})
describe('tagRects', () => {
const measure = (s: string) => s.length * 7
it('places the first tag at the top-left corner', () => {
const rects = tagRects(
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
100,
100,
measure
)
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
expect(rects[0].w).toBe(measure('01') + 8)
})
it('moves a colliding tag to a different corner', () => {
const boxes = [
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
]
const rects = tagRects(boxes, 100, 100, measure)
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
expect(sameSpot).toBe(false)
})
})
describe('fromBoundingBoxes', () => {
it('converts pixel boxes to normalized regions with metadata', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.4,
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
})
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
type: 'obj',
text: '',
desc: '',
palette: []
})
})
it('drops entries that are not bounding boxes', () => {
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
})
it('guards against zero dimensions', () => {
const boxes: BoundingBox[] = [
{
x: 5,
y: 5,
width: 5,
height: 5,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
x: 5,
y: 5,
w: 5,
h: 5
})
})
})
describe('toBoundingBoxes', () => {
it('rounds normalized regions back to pixels and copies the palette', () => {
const palette = ['#abc']
const regions: Region[] = [
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
]
const [box] = toBoundingBoxes(regions, 1000, 1000)
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
expect(box.metadata.palette).toEqual(['#abc'])
expect(box.metadata.palette).not.toBe(palette)
})
it('round-trips from pixels to regions and back', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
const restored = toBoundingBoxes(
fromBoundingBoxes(boxes, 1000, 1000),
1000,
1000
)
expect(restored).toEqual(boxes)
})
})

View File

@@ -0,0 +1,246 @@
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
export type HitMode =
| 'move'
| 'draw'
| 'resize-tl'
| 'resize-tr'
| 'resize-bl'
| 'resize-br'
| 'resize-t'
| 'resize-b'
| 'resize-l'
| 'resize-r'
export interface Region extends BoundingBoxMetadata {
x: number
y: number
w: number
h: number
}
interface BoxCandidate {
index: number
mode: HitMode
}
interface TagRect {
x: number
y: number
w: number
h: number
tag: string
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function normalizeBox(b: Region): Region {
let { x, y, w, h } = b
if (w < 0) {
x += w
w = -w
}
if (h < 0) {
y += h
h = -h
}
x = clamp01(x)
y = clamp01(y)
w = Math.min(w, 1 - x)
h = Math.min(h, 1 - y)
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
}
function rectHitTest(
mx: number,
my: number,
x1: number,
y1: number,
x2: number,
y2: number,
rx: number,
ry: number
): HitMode | null {
const h = (cx: number, cy: number) =>
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
if (h(x1, y1)) return 'resize-tl'
if (h(x2, y1)) return 'resize-tr'
if (h(x1, y2)) return 'resize-bl'
if (h(x2, y2)) return 'resize-br'
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
return null
}
export function applyDrag(
mode: HitMode,
start: Region,
dx: number,
dy: number
): Region {
let { x, y, w, h } = start
switch (mode) {
case 'move':
x += dx
y += dy
x = clamp01(Math.min(x, 1 - w))
y = clamp01(Math.min(y, 1 - h))
break
case 'draw':
case 'resize-br':
w += dx
h += dy
break
case 'resize-tl':
x += dx
y += dy
w -= dx
h -= dy
break
case 'resize-tr':
y += dy
w += dx
h -= dy
break
case 'resize-bl':
x += dx
w -= dx
h += dy
break
case 'resize-t':
y += dy
h -= dy
break
case 'resize-b':
h += dy
break
case 'resize-l':
x += dx
w -= dx
break
case 'resize-r':
w += dx
break
}
return mode === 'move'
? { ...start, x, y }
: normalizeBox({ ...start, x, y, w, h })
}
export function boxesAt(
regions: readonly Region[],
mxN: number,
myN: number,
handlePx: number,
logW: number,
logH: number,
activeIdx: number
): BoxCandidate[] {
const rx = handlePx / Math.max(1, logW)
const ry = handlePx / Math.max(1, logH)
const res: BoxCandidate[] = []
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
if (mode) res.push({ index: i, mode })
}
const ai = res.findIndex((c) => c.index === activeIdx)
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
return res
}
export function tagRects(
regions: readonly Region[],
logW: number,
logH: number,
measureWidth: (s: string) => number,
height = 14
): TagRect[] {
const placed: TagRect[] = []
const rects: TagRect[] = []
const hits = (a: TagRect, b: TagRect) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const x1 = b.x * logW
const y1 = b.y * logH
const x2 = (b.x + b.w) * logW
const y2 = (b.y + b.h) * logH
const tag = String(i + 1).padStart(2, '0')
const w = measureWidth(tag) + 8
let pick: [number, number] = [x1, y1]
for (const [cx, cy] of [
[x1, y1],
[x2 - w, y1],
[x2 - w, y2 - height],
[x1, y2 - height]
] as const) {
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
if (!placed.some((p) => hits(candidate, p))) {
pick = [cx, cy]
break
}
}
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
placed.push(r)
rects[i] = r
}
return rects
}
function isBoundingBox(b: unknown): b is BoundingBox {
if (!b || typeof b !== 'object') return false
const box = b as Record<string, unknown>
return (
typeof box.x === 'number' &&
typeof box.y === 'number' &&
typeof box.width === 'number' &&
typeof box.height === 'number'
)
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
height: number
): Region[] {
const w = width || 1
const h = height || 1
return boxes.filter(isBoundingBox).map((box) => {
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
return {
x: box.x / w,
y: box.y / h,
w: box.width / w,
h: box.height / h,
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
}
})
}
export function toBoundingBoxes(
regions: readonly Region[],
width: number,
height: number
): BoundingBox[] {
return regions.map((r) => ({
x: Math.round(r.x * width),
y: Math.round(r.y * height),
width: Math.round(r.w * width),
height: Math.round(r.h * height),
metadata: {
type: r.type,
text: r.text,
desc: r.desc,
palette: r.palette.slice()
}
}))
}

View File

@@ -0,0 +1,249 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
el.focus = () => {}
el.setPointerCapture = () => {}
el.releasePointerCapture = () => {}
return el
}
function makeNode() {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const pe = (
clientX: number,
clientY: number,
over: Partial<PointerEvent> = {}
) =>
({
button: 0,
clientX,
clientY,
altKey: false,
pointerId: 1,
preventDefault: () => {},
stopPropagation: () => {},
...over
}) as unknown as PointerEvent
const flush = async () => {
await Promise.resolve()
await nextTick()
}
type Api = ReturnType<typeof useBoundingBoxes>
interface Captured extends Api {
canvasEl: ShallowRef<HTMLCanvasElement | null>
modelValue: Ref<BoundingBox[]>
}
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial)
const api = useBoundingBoxes('1', {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
captured = { canvasEl, modelValue, ...api }
return () => h('div')
}
})
render(Harness)
captured!.canvasEl.value = makeCanvas()
return captured!
}
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useBoundingBoxes initialization', () => {
it('derives regions from the initial model value', () => {
const c = setup([box()])
expect(c.hasRegions.value).toBe(true)
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
})
it('exposes an aspect-ratio canvas style from the node width/height', () => {
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
})
it('starts with no active region when empty', () => {
const c = setup()
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
})
describe('useBoundingBoxes drawing', () => {
it('draws a new region and syncs it to the model value', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
it('changes the active region type', async () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
it('clears all regions', async () => {
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
})
})
describe('useBoundingBoxes inline editor', () => {
it('opens on double click and commits the description', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
it('closes the inline editor on Escape', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
expect(c.canvasCursor.value).toBe('crosshair')
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})

View File

@@ -0,0 +1,614 @@
import { useElementSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import type {
HitMode,
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
interface InlineEditorState {
value: string
style: Record<string, string>
index: number
}
interface UseBoundingBoxesOptions {
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
modelValue: Ref<BoundingBox[]>
}
export function useBoundingBoxes(
nodeId: string,
{
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
}: UseBoundingBoxesOptions
) {
const focused = ref(false)
const drawing = ref(false)
const dragMode = ref<HitMode | null>(null)
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
const boxAtStart = ref<Region | null>(null)
const hoverIndex = ref<number | null>(null)
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const { width: containerWidth } = useElementSize(canvasContainer)
const litegraphNode = computed(() =>
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
)
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isNodeSelected = computed(() =>
selectedNodeIds.value.has(String(nodeId))
)
function dimWidget(name: 'width' | 'height'): number | undefined {
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
return typeof v === 'number' && v > 0 ? v : undefined
}
const widthValue = computed(() => dimWidget('width') ?? 1024)
const heightValue = computed(() => dimWidget('height') ?? 1024)
const state = ref({
regions: fromBoundingBoxes(
modelValue.value ?? [],
widthValue.value,
heightValue.value
)
})
const activeIndex = ref(state.value.regions.length ? 0 : -1)
const aspectRatio = computed(
() => `${widthValue.value} / ${heightValue.value}`
)
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
const activeRegion = computed(() =>
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
)
const hasRegions = computed(() => state.value.regions.length > 0)
function clampToCanvas(n: number) {
return Math.max(0, Math.min(1, n))
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
}
function pointerNorm(e: PointerEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
let rafHandle = 0
function requestDraw() {
if (rafHandle) return
rafHandle = requestAnimationFrame(() => {
rafHandle = 0
drawCanvas()
})
}
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
return ctx.measureText(s).width
}
function drawCanvas() {
const el = canvasEl.value
if (!el) return
const { w: W, h: H } = logicalSize()
const dpr = window.devicePixelRatio || 1
const bw = Math.max(1, Math.round(W * dpr))
const bh = Math.max(1, Math.round(H * dpr))
if (el.width !== bw || el.height !== bh) {
el.width = bw
el.height = bh
}
const ctx = el.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (bgImage.value) {
ctx.drawImage(bgImage.value, 0, 0, W, H)
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
ctx.fillRect(0, 0, W, H)
}
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
.map((_, i) => i)
.filter((i) => i !== aIdx)
.reverse()
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
ctx.font = 'bold 11px monospace'
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
for (const i of order) {
const b = state.value.regions[i]
const active = i === aIdx
const pal = (b.palette || []).filter(Boolean)
const col = pal.length ? pal[0] : '#8c8c8c'
const x1 = b.x * W
const y1 = b.y * H
const x2 = (b.x + b.w) * W
const y2 = (b.y + b.h) * H
const w = x2 - x1
const h = y2 - y1
const hovered = i === hoverIndex.value || active
if (active) {
ctx.fillStyle = 'rgba(26,26,26,0.88)'
ctx.fillRect(x1, y1, w, h)
}
ctx.fillStyle = col + (hovered ? '3a' : '22')
ctx.fillRect(x1, y1, w, h)
const lw = active ? 2 : hovered ? 1.5 : 1
ctx.strokeStyle = col
ctx.lineWidth = lw
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
if (pal.length) {
const sw = w / pal.length
const sh = 7
for (let p = 0; p < pal.length; p++) {
const sx = x1 + Math.round(p * sw)
ctx.fillStyle = pal[p]
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
}
}
ctx.save()
ctx.beginPath()
ctx.rect(x1, y1, w, h)
ctx.clip()
let body = b.desc || ''
if (b.type === 'text' && b.text)
body = `"${b.text}"` + (body ? `${body}` : '')
if (body) {
ctx.font = '12px monospace'
ctx.fillStyle = readableTextColor(col)
const pad = 4
const lh = 14
let ty = y1 + 15 + 12
for (const line of wrapLines(ctx, body, w - pad * 2)) {
if (ty > y1 + h) break
ctx.fillText(line, x1 + pad, ty)
ty += lh
}
}
const tr = tag_rects[i]
ctx.font = 'bold 11px monospace'
ctx.fillStyle = col
ctx.fillRect(tr.x, tr.y, tr.w, 14)
if (i === hoverTagIndex.value) {
ctx.fillStyle = 'rgba(255,255,255,0.25)'
ctx.fillRect(tr.x, tr.y, tr.w, 14)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
}
ctx.fillStyle = textOnColor(col)
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
ctx.restore()
}
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxW: number
): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/)) {
if (!word) continue
const test = line ? `${line} ${word}` : word
if (line && ctx.measureText(test).width > maxW) {
out.push(line)
line = word
} else {
line = test
}
}
out.push(line)
}
return out
}
const hitTestPoint = (mN: { x: number; y: number }) => {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
const titleAt = (mN: { x: number; y: number }) => {
const el = canvasEl.value
if (!el) return null
const ctx = el.getContext('2d')
if (!ctx) return null
const { w: W, h: H } = logicalSize()
const rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
const px = mN.x * W
const py = mN.y * H
for (let i = state.value.regions.length - 1; i >= 0; i--) {
const r = rects[i]
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
return i
}
return null
}
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
const activeResize = cands.find(
(c) => c.index === activeIndex.value && c.mode !== 'move'
)
if (activeResize && !cycle) return activeResize
const ti = titleAt(mN)
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
if (cycle && cands.length > 1) {
const pos = cands.findIndex((c) => c.index === activeIndex.value)
return cands[(pos + 1) % cands.length]
}
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
canvasEl.value?.focus()
hoverTagIndex.value = null
hoverIndex.value = null
const mN = pointerNorm(e)
const hit = pickForSelection(mN, e.altKey)
if (hit) {
activeIndex.value = hit.index
dragMode.value = hit.mode
boxAtStart.value = { ...state.value.regions[hit.index] }
} else {
dragMode.value = 'draw'
const nb: Region = {
x: mN.x,
y: mN.y,
w: 0,
h: 0,
type: 'obj',
text: '',
desc: '',
palette: []
}
state.value.regions.push(nb)
activeIndex.value = state.value.regions.length - 1
boxAtStart.value = { ...nb }
}
drawing.value = true
dragStartNorm.value = mN
canvasEl.value?.setPointerCapture(e.pointerId)
e.preventDefault()
requestDraw()
}
function onDocPointerMove(e: PointerEvent) {
if (
!drawing.value ||
!boxAtStart.value ||
!dragStartNorm.value ||
!dragMode.value
)
return
const mN = pointerNorm(e)
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
requestDraw()
}
function onDocPointerUp(e: PointerEvent) {
if (!drawing.value) return
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
removeRegion(activeIndex.value)
}
syncState()
}
function onCanvasPointerMove(e: PointerEvent) {
if (drawing.value) onDocPointerMove(e)
else onPointerMove(e)
}
function onPointerMove(e: PointerEvent) {
if (drawing.value) return
const mN = pointerNorm(e)
const ti = titleAt(mN)
const hit = hitTestPoint(mN)
const hb = ti !== null ? ti : hit ? hit.index : null
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
hoverTagIndex.value = ti
hoverIndex.value = hb
requestDraw()
}
}
function onPointerLeave() {
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
hoverTagIndex.value = null
hoverIndex.value = null
requestDraw()
}
}
const canvasCursor = computed(() =>
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
)
function onDoubleClick(e: MouseEvent) {
e.preventDefault()
const mN = pointerNormFromMouse(e)
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
if (!target) return
openInlineEditor(target.index)
}
function pointerNormFromMouse(e: MouseEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
function openInlineEditor(index: number) {
const b = state.value.regions[index]
if (!b) return
activeIndex.value = index
const { w: W, h: H } = logicalSize()
const w = Math.min(W, Math.max(70, b.w * W))
const h = Math.min(H, Math.max(42, b.h * H))
const left = Math.max(0, Math.min(b.x * W, W - w))
const top = Math.max(0, Math.min(b.y * H, H - h))
inlineEditor.value = {
value: b.desc || '',
index,
style: {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`,
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
}
}
void nextTick(() => {
inlineEditorEl.value?.focus()
inlineEditorEl.value?.select()
})
}
function onInlineKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
inlineEditor.value = null
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
commitInlineEditor()
}
}
function commitInlineEditor() {
const ed = inlineEditor.value
if (!ed) return
const b = state.value.regions[ed.index]
if (b) b.desc = ed.value
inlineEditor.value = null
syncState()
}
function onCanvasKeyDown(e: KeyboardEvent) {
if (drawing.value) return
const idx = activeIndex.value
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
e.preventDefault()
e.stopPropagation()
removeRegion(idx)
syncState()
}
}
function removeRegion(i: number) {
state.value.regions.splice(i, 1)
if (!state.value.regions.length) activeIndex.value = -1
else if (i <= activeIndex.value)
activeIndex.value = Math.max(0, activeIndex.value - 1)
}
function setActiveType(t: 'obj' | 'text') {
if (activeRegion.value) {
activeRegion.value.type = t
syncState()
}
}
function clearAll() {
state.value.regions = []
activeIndex.value = -1
syncState()
}
function syncState() {
modelValue.value = toBoundingBoxes(
state.value.regions,
widthValue.value,
heightValue.value
)
requestDraw()
}
watch(containerWidth, () => requestDraw())
watch(
() => state.value.regions.length,
() => requestDraw()
)
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
if (!node) return
const snap = (v: number) =>
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
const targetW = snap(naturalWidth)
const targetH = snap(naturalHeight)
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && widthWidget.value !== targetW) {
widthWidget.value = targetW
widthWidget.callback?.(targetW)
}
if (heightWidget && heightWidget.value !== targetH) {
heightWidget.value = targetH
heightWidget.callback?.(targetH)
}
}
let lastBgUrl = ''
function updateBgImage() {
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('background')
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
const url = inputNode
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
: undefined
if (!url) {
if (bgImage.value) {
bgImage.value = null
lastBgUrl = ''
requestDraw()
}
return
}
if (url === lastBgUrl) return
lastBgUrl = url
const currentUrl = url
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
if (currentUrl !== lastBgUrl) return
bgImage.value = img
applyImageDimensions(img.naturalWidth, img.naturalHeight)
requestDraw()
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
if (rafHandle) cancelAnimationFrame(rafHandle)
})
return {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors: MAX_ELEMENT_COLORS,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState
}
}

View File

@@ -21,6 +21,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -50,7 +51,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -62,7 +67,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -81,7 +90,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'clip'
)
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
@@ -103,7 +116,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'model'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -229,7 +246,11 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([node.id]),
'image'
)
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
@@ -279,7 +300,11 @@ describe('installErrorClearingHooks lifecycle', () => {
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
seedRequiredInputMissingNodeError(
store,
createNodeExecutionId([lateNode.id]),
'value'
)
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,

View File

@@ -34,6 +34,7 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { appendNodeExecutionId } from '@/types/nodeIdentification'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
@@ -83,7 +84,7 @@ function installNodeHooks(node: LGraphNode): void {
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? `${hostExecId}:${promotedSource.nodeId}`
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name

View File

@@ -703,3 +703,55 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
})
describe('Pre-remove vueNodeData drain', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('drops vueNodeData entry before node.onRemoved fires', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.has(String(node.id))).toBe(true)
let dataPresentInOnRemoved: boolean | undefined
node.onRemoved = () => {
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
}
graph.remove(node)
expect(
dataPresentInOnRemoved,
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
).toBe(false)
})
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
const graph = new LGraph()
const nodeA = new LGraphNode('a')
const nodeB = new LGraphNode('b')
graph.add(nodeA)
graph.add(nodeB)
const { vueNodeData } = useGraphNodeManager(graph)
expect(vueNodeData.size).toBe(2)
const beforeRemovedSpy = vi.fn()
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
graph.clear()
expect(
beforeRemovedSpy,
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
).toHaveBeenCalledTimes(2)
expect(
vueNodeData.size,
'node:before-removed listener must drain vueNodeData when clear() removes every node'
).toBe(0)
})
})

View File

@@ -30,6 +30,8 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -82,6 +84,7 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
removable?: boolean
values?: unknown
}
/** Input specification from node definition */
@@ -94,7 +97,7 @@ export interface SafeWidgetData {
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
@@ -137,7 +140,7 @@ export interface GraphNodeManager {
vueNodeData: ReadonlyMap<string, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
getNode(id: WorkflowNodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
@@ -211,7 +214,8 @@ function extractWidgetDisplayOptions(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
read_only: widget.options.read_only,
removable: widget.options.removable
}
}
@@ -225,7 +229,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: string
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
@@ -516,8 +520,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
return nodeRefs.get(String(id))
}
const syncWithGraph = () => {
@@ -608,27 +612,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
/**
* Handles node removal from the graph - cleans up all references
*/
const dropNodeReferences = (node: LGraphNode) => {
const id = String(node.id)
nodeRefs.delete(id)
vueNodeData.delete(id)
}
const handleNodeRemoved = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
// Call original callback if provided
if (originalCallback) {
originalCallback(node)
}
originalCallback?.(node)
}
/**
@@ -637,7 +634,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
@@ -645,15 +643,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
graph.events.removeEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
/**
* Sets up event listeners - now simplified with extracted handlers
*/
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
@@ -669,6 +669,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const beforeNodeRemovedListener = (
e: CustomEvent<{ node: LGraphNode }>
) => {
dropNodeReferences(e.detail.node)
}
graph.events.addEventListener(
'node:before-removed',
beforeNodeRemovedListener
)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
@@ -817,11 +827,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,
originalOnNodeRemoved || undefined,
originalOnTrigger || undefined
originalOnTrigger || undefined,
beforeNodeRemovedListener
)
}

View File

@@ -137,6 +137,18 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
const setup = createSetup()
const parentGraph = setup.subgraphNode.graph!
parentGraph.add(setup.subgraphNode)
parentGraph.remove(setup.subgraphNode)
expect(setup.subgraphNode.graph).toBeNull()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(() => promotedPreviews.value).not.toThrow()
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })

View File

@@ -6,7 +6,11 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import {
appendNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
interface PromotedPreview {
sourceNodeId: string
@@ -38,7 +42,7 @@ export function usePromotedPreviews(
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: string,
leafExecutionId: NodeExecutionId,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
@@ -68,6 +72,7 @@ export function usePromotedPreviews(
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
if (node.isDetached) return []
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
@@ -121,7 +126,7 @@ export function usePromotedPreviews(
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
`${leafHostLocator}:${leaf.sourceNodeId}`,
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
interiorNode
)
if (!urls?.length) return []

View File

@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
const scopes: EffectScope[] = []
afterEach(() => {
while (scopes.length) scopes.pop()?.stop()
})
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
addColor()
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
})
it('removes a color by index', () => {
const { modelValue, remove } = setup(['#a', '#b', '#c'])
remove(1)
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
})
it('reorders via drag when the pointer crosses another swatch', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#b', '#a'])
})
it('cancels a stale drag when the primary button is no longer pressed', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
})

View File

@@ -0,0 +1,114 @@
import { useEventListener } from '@vueuse/core'
import type { Ref, ShallowRef } from 'vue'
import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
modelValue.value = next
}
function remove(i: number) {
const next = modelValue.value.slice()
next.splice(i, 1)
modelValue.value = next
}
function addColor() {
modelValue.value = [...modelValue.value, '#ffffff']
}
const drag = ref<{
index: number
startX: number
startY: number
active: boolean
} | null>(null)
function onPointerDown(i: number, e: PointerEvent) {
if (e.button !== 0) return
drag.value = {
index: i,
startX: e.clientX,
startY: e.clientY,
active: false
}
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
const d = drag.value
if (!d) return
if ((e.buttons & 1) === 0) {
drag.value = null
return
}
if (!d.active) {
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
return
d.active = true
}
const rows =
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
if (!rows) return
for (const other of rows) {
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
const r = other.getBoundingClientRect()
if (
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top - 6 &&
e.clientY <= r.bottom + 6
) {
const oi = parseInt(other.dataset.index || '-1', 10)
if (oi < 0) continue
const next = modelValue.value.slice()
const [moved] = next.splice(d.index, 1)
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
modelValue.value = next
drag.value = null
return
}
}
})
useEventListener(document, 'pointerup', () => {
drag.value = null
})
useEventListener(document, 'pointercancel', () => {
drag.value = null
})
return {
openPicker,
onPickerInput,
remove,
addColor,
onPointerDown
}
}

View File

@@ -0,0 +1,443 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
import {
detectGamutFromChromaticities,
gamutToSrgbMatrix
} from '@/renderer/hdr/colorGamut'
import {
HDR_VIEWER_FRAGMENT_SHADER,
HDR_VIEWER_VERTEX_SHADER
} from '@/renderer/hdr/hdrViewerShader'
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
import {
computeChannelHistograms,
computeImageStats
} from '@/renderer/hdr/hdrStats'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
const MIN_ZOOM = 0.05
const MAX_ZOOM = 64
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
export const CHANNEL_MODES: ChannelMode[] = [
'rgb',
'r',
'g',
'b',
'a',
'luminance'
]
const CHANNEL_INDEX: Record<ChannelMode, number> = {
rgb: 0,
r: 1,
g: 2,
b: 3,
a: 4,
luminance: 5
}
export interface PixelReadout {
x: number
y: number
r: number
g: number
b: number
a: number | null
}
interface ExrTexData {
header?: { chromaticities?: ChromaticityCoords }
}
function createLoader(url: string) {
const filename = getImageFilenameFromUrl(url)
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
const loader = new EXRLoader()
loader.setDataType(THREE.FloatType)
return loader
}
function makeReader(
data: ArrayLike<number>,
type: THREE.TextureDataType
): (index: number) => number {
if (type === THREE.HalfFloatType) {
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
}
return (index) => data[index]
}
function loadHdrTexture(
url: string
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
return new Promise((resolve, reject) => {
createLoader(url).load(
url,
(texture, texData) => {
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
resolve({
texture,
gamut: detectGamutFromChromaticities(chromaticities)
})
},
undefined,
reject
)
})
}
export function useHdrViewer() {
const exposureStops = ref(0)
const dither = ref(true)
const clipWarnings = ref(false)
const gamut = ref<GamutName>('sRGB')
const channel = ref<ChannelMode>('rgb')
const loading = ref(true)
const error = ref<string | null>(null)
const dimensions = ref<string | null>(null)
const stats = ref<ImageStats | null>(null)
const histograms = shallowRef<ChannelHistograms | null>(null)
const pixel = ref<PixelReadout | null>(null)
const histogram = computed<Uint32Array | null>(() => {
const channelHistograms = histograms.value
if (!channelHistograms) return null
switch (channel.value) {
case 'r':
return channelHistograms.r
case 'g':
return channelHistograms.g
case 'b':
return channelHistograms.b
case 'a':
return channelHistograms.a
default:
return channelHistograms.luminance
}
})
const containerRef = shallowRef<HTMLElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let viewport: WebGLViewport | null = null
let scene: THREE.Scene | null = null
let camera: THREE.OrthographicCamera | null = null
let material: THREE.ShaderMaterial | null = null
let mesh: THREE.Mesh | null = null
let texture: THREE.Texture | null = null
let imageAspect = 1
let frameRequested = false
let readSample: ((index: number) => number) | null = null
let imageWidth = 0
let imageHeight = 0
let imageChannels = 4
const raycaster = new THREE.Raycaster()
const pointerNdc = new THREE.Vector2()
function requestRender() {
if (!renderer || frameRequested) return
frameRequested = true
requestAnimationFrame(() => {
frameRequested = false
if (renderer && scene && camera) renderer.render(scene, camera)
})
}
function containerSize() {
const el = containerRef.value
return {
width: el?.clientWidth || 1,
height: el?.clientHeight || 1
}
}
function updateProjection() {
if (!camera) return
const { width, height } = containerSize()
const halfH = 0.5
const halfW = (0.5 * width) / height
camera.left = -halfW
camera.right = halfW
camera.top = halfH
camera.bottom = -halfH
camera.updateProjectionMatrix()
}
function fitView() {
if (!camera) return
const { width, height } = containerSize()
const containerAspect = width / height
camera.zoom = Math.min(1, containerAspect / imageAspect)
camera.position.set(0, 0, 1)
camera.updateProjectionMatrix()
requestRender()
}
function applyUniforms() {
if (!material) return
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
material.uniforms.uDither.value = dither.value
material.uniforms.uClipWarnings.value = clipWarnings.value
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
const m = gamutToSrgbMatrix(gamut.value)
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
m[0],
m[1],
m[2],
m[3],
m[4],
m[5],
m[6],
m[7],
m[8]
)
requestRender()
}
function buildScene() {
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
viewport = new WebGLViewport(renderer)
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x0a0a0a, 1)
scene = new THREE.Scene()
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
camera.position.set(0, 0, 1)
material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: HDR_VIEWER_VERTEX_SHADER,
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
uniforms: {
uImage: { value: null },
uGamutToSRGB: { value: new THREE.Matrix3() },
uGain: { value: 1 },
uChannel: { value: 0 },
uDither: { value: true },
uClipWarnings: { value: false },
uClipRange: { value: new THREE.Vector2(0, 1) }
}
})
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
scene.add(mesh)
}
function resize() {
if (!renderer) return
const { width, height } = containerSize()
renderer.setSize(width, height, false)
updateProjection()
requestRender()
}
function setTexture(loaded: THREE.DataTexture) {
if (!material || !mesh) return
loaded.colorSpace = THREE.LinearSRGBColorSpace
loaded.minFilter = THREE.LinearFilter
loaded.magFilter = THREE.LinearFilter
loaded.needsUpdate = true
const { width, height, data } = loaded.image
texture = loaded
imageAspect = width / height
mesh.scale.set(imageAspect, 1, 1)
material.uniforms.uImage.value = loaded
dimensions.value = `${width} x ${height}`
if (!data) return
imageWidth = width
imageHeight = height
imageChannels = data.length / (width * height)
readSample = makeReader(data, loaded.type)
stats.value = computeImageStats(readSample, data.length, imageChannels)
histograms.value = computeChannelHistograms(
readSample,
data.length,
imageChannels
)
}
async function mount(container: HTMLElement, url: string) {
containerRef.value = container
loading.value = true
error.value = null
try {
buildScene()
container.appendChild(renderer!.domElement)
renderer!.domElement.classList.add('block', 'size-full')
resize()
applyUniforms()
attachInteractions(renderer!.domElement)
viewport!.observeResize(container, resize)
const { texture: loaded, gamut: detectedGamut } =
await loadHdrTexture(url)
if (!material || !mesh) {
loaded.dispose()
return
}
gamut.value = detectedGamut
setTexture(loaded)
applyUniforms()
fitView()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
dispose()
} finally {
loading.value = false
}
}
function normalizeExposure() {
const max = stats.value?.max ?? 0
exposureStops.value = max > 0 ? -Math.log2(max) : 0
}
function attachInteractions(canvas: HTMLCanvasElement) {
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onHoverMove)
canvas.addEventListener('pointerleave', onHoverLeave)
}
function onWheel(event: WheelEvent) {
if (!camera) return
event.preventDefault()
const factor = Math.exp(-event.deltaY * 0.001)
const nextZoom = THREE.MathUtils.clamp(
camera.zoom * factor,
MIN_ZOOM,
MAX_ZOOM
)
camera.zoom = nextZoom
camera.updateProjectionMatrix()
requestRender()
}
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
null
function onPointerDown(event: PointerEvent) {
if (!camera) return
dragStart = {
x: event.clientX,
y: event.clientY,
camX: camera.position.x,
camY: camera.position.y
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!camera || !dragStart) return
const { height } = containerSize()
const worldPerPixel = 1 / (height * camera.zoom)
camera.position.x =
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
camera.position.y =
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
requestRender()
}
function onPointerUp() {
dragStart = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
function onHoverMove(event: PointerEvent) {
if (!camera || !mesh || !renderer || dragStart || !readSample) return
const rect = renderer.domElement.getBoundingClientRect()
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
raycaster.setFromCamera(pointerNdc, camera)
const hit = raycaster.intersectObject(mesh)[0]
if (!hit?.uv) {
pixel.value = null
return
}
const col = THREE.MathUtils.clamp(
Math.floor(hit.uv.x * imageWidth),
0,
imageWidth - 1
)
const row = THREE.MathUtils.clamp(
Math.floor(hit.uv.y * imageHeight),
0,
imageHeight - 1
)
const base = (row * imageWidth + col) * imageChannels
pixel.value = {
x: col,
y: imageHeight - 1 - row,
r: readSample(base),
g: readSample(base + 1),
b: readSample(base + 2),
a: imageChannels === 4 ? readSample(base + 3) : null
}
}
function onHoverLeave() {
pixel.value = null
}
function dispose() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
if (renderer) {
renderer.domElement.removeEventListener('wheel', onWheel)
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
renderer.domElement.removeEventListener('pointermove', onHoverMove)
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
}
viewport?.disposeRenderer()
texture?.dispose()
material?.dispose()
mesh?.geometry.dispose()
renderer = null
viewport = null
scene = null
camera = null
material = null
mesh = null
texture = null
readSample = null
}
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
onUnmounted(dispose)
return {
exposureStops,
dither,
clipWarnings,
gamut,
channel,
loading,
error,
dimensions,
stats,
histogram,
pixel,
mount,
dispose,
fitView,
normalizeExposure
}
}

View File

@@ -522,6 +522,22 @@ describe('hasUnpromotedWidgets', () => {
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('returns false (does not throw) when SubgraphNode is detached', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})
describe('isLinkedPromotion', () => {

View File

@@ -633,6 +633,7 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
if (subgraphNode.isDetached) return false
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>

View File

@@ -1,5 +1,9 @@
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
import {
zAutogrowOptions,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
(inputSpec: InputSpecV2) => string[]
> = {
COMFY_AUTOGROW_V3: resolveAutogrowType,
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
COMFY_MATCHTYPE_V3: (input) =>
zMatchTypeOptions
.safeParse(input)
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
: input.type.split(',')
}
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
const template = parsed.data?.[1]?.template
if (!template) return []
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
template.required,
template.optional
]
return inputTypes.flatMap((inputType) =>
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
resolveInputType(transformInputSpecV1ToV2(v, { name }))
)
)
}
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}

View File

@@ -1,7 +1,9 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -47,6 +49,22 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
function addDynamicGroup(
node: LGraphNode,
template: object,
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
) {
const options: Record<string, unknown> = { template }
if (min !== undefined) options.min = min
if (max !== undefined) options.max = max
addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
name,
isOptional: false
})
)
}
function addAutogrow(node: LGraphNode, template: unknown) {
addNodeInput(
node,
@@ -287,3 +305,101 @@ describe('Autogrow', () => {
])
})
})
describe('Dynamic Groups', () => {
const stringTemplate = { required: { a: ['STRING', {}] } }
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
const widgetNamed = (node: LGraphNode, name: string) =>
node.widgets!.find((w) => w.name === name)!
test('renders min rows on creation', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('add row appends a new row up to max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
expect(widgetNames(node)).toStrictEqual(['g'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// At max, further adds are ignored.
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('remove row renumbers later rows', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const row0Field = widgetNamed(node, 'g.0.a')
const row2Field = widgetNamed(node, 'g.2.a')
widgetNamed(node, 'g.__row__1').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Row 0 is untouched; the former row 2 shifts down into row 1.
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
})
test('rows below min are not removable', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
// Attempting to remove a protected row is a no-op.
widgetNamed(node, 'g.__row__0').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('canvas click removes a row only on the remove hit target', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const header = widgetNamed(node, 'g.__row__1')
const up = { type: 'pointerup' } as CanvasPointerEvent
const down = { type: 'pointerdown' } as CanvasPointerEvent
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
// Releasing away from the remove target does nothing.
header.mouse?.(up, [0, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// A pointerdown on the target does nothing (only release acts).
header.mouse?.(down, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Releasing on the target removes the row.
header.mouse?.(up, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
})
})

View File

@@ -2,10 +2,12 @@ import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { t } from '@/i18n'
import type {
ISlotType,
INodeInputSlot,
INodeOutputSlot
INodeOutputSlot,
Point
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -13,11 +15,14 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
zAutogrowOptions,
zDynamicComboInputSpec,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -28,6 +33,15 @@ import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
type DynamicGroupState = {
min: number
max: number
inputSpecs: InputSpecV2[]
}
type DynamicGroupNode = LGraphNode & {
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
}
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
@@ -210,7 +224,321 @@ function dynamicComboWidget(
return { widget, minWidth, minHeight }
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
function withComfyDynamicGroup(
node: LGraphNode
): asserts node is DynamicGroupNode {
if (node.comfyDynamic?.dynamicGroup) return
node.comfyDynamic ??= {}
node.comfyDynamic.dynamicGroup = {}
}
const ROW_MARKER = '__row__'
const rowHeaderName = (group: string, row: number) =>
`${group}.${ROW_MARKER}${row}`
const fieldName = (group: string, row: number, field: string) =>
`${group}.${row}.${field}`
/** Extract the row index from a header widget name, or `undefined`. */
function headerRowIndex(group: string, name: string): number | undefined {
const prefix = `${group}.${ROW_MARKER}`
if (!name.startsWith(prefix)) return undefined
const row = Number(name.slice(prefix.length))
return Number.isInteger(row) ? row : undefined
}
/** Rename a field that sits above the removed row, shifting its index down. */
function shiftedFieldName(
group: string,
name: string,
removedRow: number
): string | undefined {
const prefix = `${group}.`
if (!name.startsWith(prefix)) return undefined
const rest = name.slice(prefix.length)
const dot = rest.indexOf('.')
if (dot === -1) return undefined
const row = Number(rest.slice(0, dot))
if (!Number.isInteger(row) || row <= removedRow) return undefined
return fieldName(group, row - 1, rest.slice(dot + 1))
}
const belongsToRow = (group: string, name: string, row: number): boolean =>
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
const CANVAS_MARGIN = 15
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
function drawGroupButton(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
label: string,
disabled: boolean
): void {
const height = LiteGraph.NODE_WIDGET_HEIGHT
ctx.save()
if (disabled) ctx.globalAlpha *= 0.5
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
ctx.beginPath()
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
height * 0.5
])
ctx.fill()
if (!disabled) ctx.stroke()
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
ctx.textAlign = 'center'
ctx.fillText(label, width * 0.5, y + height * 0.7)
ctx.restore()
}
/** Horizontal centre of a row header's remove (✕) hit target. */
const removeButtonCenterX = (width: number) =>
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
function drawGroupRowHeader(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
label: string,
removable: boolean
): void {
const height = LiteGraph.NODE_WIDGET_HEIGHT
ctx.save()
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
ctx.textAlign = 'left'
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
if (removable) {
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
ctx.textAlign = 'center'
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
}
ctx.restore()
}
const countGroupRows = (group: string, node: LGraphNode): number =>
(node.widgets ?? []).reduce(
(count, w) =>
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
0
)
/** Build a row's header + field widgets, returning them detached from the node. */
function createRow(
group: string,
row: number,
state: DynamicGroupState,
node: DynamicGroupNode
): IBaseWidget[] {
const { addNodeInput } = useLitegraphService()
const startLen = node.widgets!.length
const header = node.addCustomWidget({
name: rowHeaderName(group, row),
type: 'dynamic_group_row',
value: row,
y: 0,
serialize: false,
callback: undefined as IBaseWidget['callback'],
draw(
this: IBaseWidget,
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
width: number,
y: number
) {
const idx = headerRowIndex(group, this.name) ?? 0
const label = t('dynamicGroup.row', { index: idx + 1 })
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
},
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
if (event.type !== 'pointerup' || !this.options?.removable) return false
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
return false
const idx = headerRowIndex(group, this.name)
if (idx !== undefined) removeRow(group, idx, node)
return true
},
options: { serialize: false, socketless: true, removable: row >= state.min }
})
header.callback = function (this: IBaseWidget) {
const idx = headerRowIndex(group, this.name)
if (idx !== undefined) removeRow(group, idx, node)
}
for (const spec of state.inputSpecs)
addNodeInput(node, {
...spec,
name: fieldName(group, row, spec.name),
display_name: spec.display_name ?? spec.name
})
return node.widgets!.splice(startLen)
}
function insertRowAfterGroup(
group: string,
node: LGraphNode,
rowWidgets: IBaseWidget[]
): void {
const lastIdx = node.widgets!.findLastIndex(
(w) => w.name === group || w.name.startsWith(`${group}.`)
)
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
}
function syncController(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
const controller = node.widgets?.find((w) => w.name === group)
if (!state || !controller) return
controller.options ??= {}
controller.options.disabled = countGroupRows(group, node) >= state.max
node.size[1] = node.computeSize([...node.size])[1]
}
function addRow(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const row = countGroupRows(group, node)
if (row >= state.max) return
insertRowAfterGroup(group, node, createRow(group, row, state, node))
syncController(group, node)
app.canvas?.setDirty(true, true)
}
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state || row < state.min) return
for (const w of remove(node.widgets!, (w) =>
belongsToRow(group, w.name, row)
))
w.onRemove?.()
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
for (const w of node.widgets ?? []) {
const headerRow = headerRowIndex(group, w.name)
if (headerRow !== undefined && headerRow > row) {
w.name = rowHeaderName(group, headerRow - 1)
w.options ??= {}
w.options.removable = headerRow - 1 >= state.min
continue
}
const shifted = shiftedFieldName(group, w.name, row)
if (shifted !== undefined) w.name = shifted
}
for (const inp of node.inputs) {
const shifted = shiftedFieldName(group, inp.name, row)
if (shifted === undefined) continue
inp.name = shifted
if (inp.widget) inp.widget.name = shifted
}
syncController(group, node)
app.canvas?.setDirty(true, true)
}
/** Rebuild the group from scratch to hold exactly `count` rows. */
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const isRowMember = (name: string) => name.startsWith(`${group}.`)
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
w.onRemove?.()
remove(node.inputs, (inp) => isRowMember(inp.name))
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
const rowWidgets: IBaseWidget[] = []
for (let row = 0; row < count; row++)
rowWidgets.push(...createRow(group, row, state, node))
node.widgets.splice(insertAt, 0, ...rowWidgets)
}
function dynamicGroupWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
_appArg: ComfyApp
) {
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
const [, { template, min, max }] = parseResult.data
const toSpecs = (
inputs: Record<string, InputSpec> | undefined,
isOptional: boolean
) =>
Object.entries(inputs ?? {}).map(([name, spec]) =>
transformInputSpecV1ToV2(spec, { name, isOptional })
)
const inputSpecs = [
...toSpecs(template.required, false),
...toSpecs(template.optional, true)
]
withComfyDynamicGroup(node)
const typedNode = node as DynamicGroupNode
typedNode.comfyDynamic.dynamicGroup[inputName] = { min, max, inputSpecs }
node.widgets ??= []
const controller = node.addCustomWidget({
name: inputName,
type: 'dynamic_group_add',
value: min,
y: 0,
serialize: true,
callback: () => addRow(inputName, typedNode),
draw(
this: IBaseWidget,
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
width: number,
y: number
) {
drawGroupButton(
ctx,
width,
y,
t('dynamicGroup.addRow'),
!!this.options?.disabled
)
},
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
if (event.type !== 'pointerup' || this.options?.disabled) return false
addRow(inputName, typedNode)
return true
},
options: { serialize: false, socketless: true, disabled: false }
})
Object.defineProperty(controller, 'value', {
get() {
return countGroupRows(inputName, typedNode)
},
set(count: unknown) {
if (typeof count !== 'number') return
rebuildRows(inputName, count, typedNode)
syncController(inputName, typedNode)
},
configurable: true
})
controller.value = min
return { widget: controller }
}
export const dynamicWidgets = {
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
}
const dynamicInputs: Record<
string,
(node: LGraphNode, inputSpec: InputSpecV2) => void

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { state } = vi.hoisted(() => ({
state: {
extension: null as { nodeCreated: (node: unknown) => void } | null,
widgetState: undefined as { options: Record<string, unknown> } | undefined
}
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: { nodeCreated: (node: unknown) => void }) => {
state.extension = ext
}
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => state.widgetState })
}))
await import('./createBoundingBoxes')
interface MockWidget {
name: string
hidden: boolean
options: Record<string, unknown>
widgetId?: string
}
function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
size: [100, 100] as [number, number],
setSize: vi.fn(),
findInputSlot: () => 0,
isInputConnected: () => connected,
widgets,
onConnectionsChange: undefined as unknown
}
}
beforeEach(() => {
state.widgetState = undefined
})
describe('Comfy.CreateBoundingBoxes extension', () => {
it('ignores nodes of other classes', () => {
const node = makeNode(true, 'SomethingElse')
state.extension!.nodeCreated(node)
expect(node.setSize).not.toHaveBeenCalled()
})
it('enlarges the node and hides width/height when a background is connected', () => {
const node = makeNode(true)
state.extension!.nodeCreated(node)
expect(node.setSize).toHaveBeenCalledWith([420, 560])
expect(node.widgets[0].hidden).toBe(true)
expect(node.widgets[1].hidden).toBe(true)
expect(node.widgets[0].options.hidden).toBe(true)
expect(node.widgets[2].hidden).toBe(false)
})
it('shows width/height when no background is connected', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(node.widgets[0].hidden).toBe(false)
expect(node.widgets[0].options.hidden).toBe(false)
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)
node.widgets[0].widgetId = 'w-0'
state.extension!.nodeCreated(node)
expect(state.widgetState.options.hidden).toBe(true)
})
it('chains a connections-change handler that re-syncs visibility', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(typeof node.onConnectionsChange).toBe('function')
})
})

View File

@@ -0,0 +1,38 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useExtensionService } from '@/services/extensionService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const DIMENSION_WIDGETS = new Set(['width', 'height'])
useExtensionService().registerExtension({
name: 'Comfy.CreateBoundingBoxes',
nodeCreated(node) {
if (node.constructor.comfyClass !== 'CreateBoundingBoxes') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 420), Math.max(oldHeight, 560)])
const widgetValueStore = useWidgetValueStore()
const syncDimensionVisibility = () => {
const slot = node.findInputSlot('background')
const hidden = slot >= 0 && node.isInputConnected(slot)
for (const widget of node.widgets ?? []) {
if (!DIMENSION_WIDGETS.has(widget.name)) continue
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
}
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
syncDimensionVisibility
)
}
})

View File

@@ -2,6 +2,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './createBoundingBoxes'
import './customWidgets'
import './dynamicPrompts'
import './editAttention'

View File

@@ -1,5 +1,7 @@
import * as THREE from 'three'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
@@ -27,8 +29,7 @@ export type Viewport3dDeps = {
viewHelperManager: ViewHelperManager
}
export class Viewport3d {
renderer: THREE.WebGLRenderer
export class Viewport3d extends WebGLViewport {
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
@@ -52,7 +53,6 @@ export class Viewport3d {
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
@@ -63,6 +63,7 @@ export class Viewport3d {
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
super(deps.renderer)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -73,7 +74,6 @@ export class Viewport3d {
this.applyTargetSize(options.width, options.height)
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
@@ -94,7 +94,7 @@ export class Viewport3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.observeResize(container, () => this.handleResize())
}
start(): void {
@@ -118,16 +118,6 @@ export class Viewport3d {
this.targetAspectRatio = width / height
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
@@ -400,29 +390,15 @@ export class Viewport3d {
this.initialRenderTimer = null
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.disposeManagers()
this.renderer.dispose()
this.renderer.domElement.remove()
this.disposeRenderer()
}
protected disposeManagers(): void {

View File

@@ -7,7 +7,6 @@ import type {
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
TWidgetValue
@@ -273,17 +272,7 @@ export class PrimitiveNode extends LGraphNode {
widgetName: 'value',
nodeTypeForBrowser: targetNode.comfyClass ?? '',
inputNameForBrowser: targetInputName,
defaultValue,
onValueChange: (widget, newValue, oldValue) => {
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
}
defaultValue
})
}

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphGroup,
LGraphNode,
LiteGraph,
LLink,
@@ -323,6 +324,96 @@ describe('Graph Clearing and Callbacks', () => {
})
})
describe('node:before-removed event', () => {
it('fires node:before-removed for a successful node removal', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
graph.events.addEventListener('node:before-removed', (e) => {
events.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
graph.remove(node)
expect(events).toHaveLength(1)
expect(events[0].node).toBe(node)
expect(events[0].graphAtDispatch).toBe(graph)
expect(node.graph).toBeNull()
})
it('does not fire node:before-removed for a node not in the graph', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when removing an LGraphGroup', () => {
const graph = new LGraph()
const group = new LGraphGroup('test-group')
graph.add(group)
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(group)
expect(fired).not.toHaveBeenCalled()
})
it('does not fire node:before-removed when ignore_remove is set', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
node.ignore_remove = true
const fired = vi.fn()
graph.events.addEventListener('node:before-removed', fired)
graph.remove(node)
expect(fired).not.toHaveBeenCalled()
expect(graph.nodes).toContain(node)
})
it('fires node:before-removed before node.onRemoved and detach', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const order: string[] = []
graph.events.addEventListener('node:before-removed', () => {
order.push(
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
)
})
node.onRemoved = () => {
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
}
graph.onNodeRemoved = (n) => {
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
}
graph.remove(node)
expect(order).toEqual([
'before-removed(graph=set)',
'onRemoved(graph=set)',
'onNodeRemoved(graph=null)'
])
})
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -375,6 +466,53 @@ describe('Subgraph Definition Garbage Collection', () => {
expect(graphRemovedNodeIds.size).toBe(2)
})
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
subgraph.events.addEventListener('node:before-removed', (e) => {
dispatched.push({
node: e.detail.node,
graphAtDispatch: e.detail.node.graph
})
})
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
for (const entry of dispatched) {
expect(entry.graphAtDispatch).toBe(subgraph)
}
})
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
const rootGraph = new LGraph()
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
const innerNode = innerNodes[0]
const order: string[] = []
subgraph.events.addEventListener('node:before-removed', () => {
order.push('before-removed')
})
innerNode.onRemoved = () => {
order.push('onRemoved')
}
subgraph.onNodeRemoved = () => {
order.push('onNodeRemoved')
}
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
})
it('subgraph definition is removed when SubgraphNode is removed', () => {
const rootGraph = new LGraph()
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)

View File

@@ -155,6 +155,13 @@ export interface BaseLGraph {
readonly rootGraph: LGraph
}
function fireNodeRemovalLifecycle(node: LGraphNode): void {
const graph: LGraph | null = node.graph
graph?.events.dispatch('node:before-removed', { node })
node.onRemoved?.()
graph?.onNodeRemoved?.(node)
}
/**
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
@@ -386,8 +393,7 @@ export class LGraph
// safe clear
if (this._nodes) {
for (const _node of this._nodes) {
_node.onRemoved?.()
this.onNodeRemoved?.(_node)
fireNodeRemovalLifecycle(_node)
}
}
@@ -1046,6 +1052,8 @@ export class LGraph
// sure? - almost sure is wrong
this.beforeChange()
this.events.dispatch('node:before-removed', { node })
const { inputs, outputs } = node
// disconnect inputs
@@ -1081,10 +1089,7 @@ export class LGraph
)
if (!hasRemainingReferences) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}

View File

@@ -829,6 +829,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this._lowQualityZoomThreshold > 0) {
this._isLowQuality = scale < this._lowQualityZoomThreshold
}
this.setDirty(true, true)
}
// Initialize link renderer if graph is available

View File

@@ -1,5 +1,5 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
@@ -51,6 +51,13 @@ export interface LGraphEventMap {
closingGraph: LGraph | Subgraph
}
/**
* Fires on the owning graph before per-node teardown begins
*/
'node:before-removed': {
node: LGraphNode
}
'node:property:changed': {
nodeId: NodeId
property: string

View File

@@ -85,6 +85,19 @@ describe('SubgraphNode Construction', () => {
expect(subgraphNode.graph).toBeNull()
})
it('should return empty widgets array (not throw) after removal', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const parentGraph = subgraphNode.graph!
parentGraph.add(subgraphNode)
parentGraph.remove(subgraphNode)
expect(subgraphNode.graph).toBeNull()
expect(() => subgraphNode.widgets).not.toThrow()
expect(subgraphNode.widgets).toEqual([])
})
subgraphTest(
'should synchronize slots with subgraph definition',
({ subgraphWithNode }) => {

View File

@@ -68,6 +68,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return this.graph.rootGraph
}
get isDetached(): boolean {
return !this.graph
}
override get displayType(): string {
return 'Subgraph node'
}

View File

@@ -1,5 +1,6 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { WidgetId } from '@/types/widgetId'
import type {
@@ -70,6 +71,7 @@ export interface IWidgetOptions<TValues = unknown> {
// Vue widget options
disabled?: boolean
removable?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean
@@ -141,6 +143,8 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IBoundingBoxesWidget
| IColorsWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -343,6 +347,19 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
value: string
}
export interface IBoundingBoxesWidget extends IBaseWidget<
BoundingBox[],
'boundingboxes'
> {
type: 'boundingboxes'
value: BoundingBox[]
}
export interface IColorsWidget extends IBaseWidget<string[], 'colors'> {
type: 'colors'
value: string[]
}
export interface RangeValue {
min: number
max: number

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('BoundingBoxesWidget', () => {
it('has the boundingboxes type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new BoundingBoxesWidget(
{
type: 'boundingboxes',
name: 'editor_state',
value: [],
options: {},
y: 0
},
node
)
expect(widget.type).toBe('boundingboxes')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -0,0 +1,16 @@
import type { IBoundingBoxesWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class BoundingBoxesWidget
extends BaseWidget<IBoundingBoxesWidget>
implements IBoundingBoxesWidget
{
override type = 'boundingboxes' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Bounding Boxes')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { ColorsWidget } from './ColorsWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('ColorsWidget', () => {
it('has the colors type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new ColorsWidget(
{ type: 'colors', name: 'palette', value: [], options: {}, y: 0 },
node
)
expect(widget.type).toBe('colors')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -0,0 +1,16 @@
import type { IColorsWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class ColorsWidget
extends BaseWidget<IColorsWidget>
implements IColorsWidget
{
override type = 'colors' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Colors')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -21,6 +21,8 @@ import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
import { ColorsWidget } from './ColorsWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { ImageCropWidget } from './ImageCropWidget'
@@ -62,6 +64,8 @@ export type WidgetTypeMap = {
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
boundingboxes: BoundingBoxesWidget
colors: ColorsWidget
[key: string]: BaseWidget
}
@@ -144,6 +148,10 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
case 'boundingboxes':
return toClass(BoundingBoxesWidget, narrowedWidget, node)
case 'colors':
return toClass(ColorsWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -387,6 +387,35 @@
"collapseAll": "Collapse all",
"expandAll": "Expand all"
},
"hdrViewer": {
"title": "HDR Viewer",
"openInHdrViewer": "Open in HDR Viewer",
"hdrImage": "HDR image",
"failedToLoad": "Failed to load HDR image",
"exposure": "Exposure",
"normalizeExposure": "Auto exposure",
"channel": "Channel",
"channels": {
"rgb": "RGB",
"r": "R",
"g": "G",
"b": "B",
"a": "Alpha",
"luminance": "Luminance"
},
"sourceGamut": "Source gamut",
"dither": "Dither",
"clipWarnings": "Clip warnings",
"fitView": "Fit",
"histogram": "Histogram",
"resolution": "Resolution",
"min": "Min",
"max": "Max",
"mean": "Mean",
"stdDev": "Std dev",
"nan": "NaN",
"inf": "Inf"
},
"manager": {
"title": "Nodes Manager",
"nodePackInfo": "Node Pack Info",
@@ -866,8 +895,8 @@
"nodes": "Nodes",
"models": "Models",
"assets": "Assets",
"workflows": "Workflows",
"templates": "Templates",
"workflows": "Work\u00adflows",
"templates": "Tem\u00adplates",
"console": "Console",
"menu": "Menu",
"imported": "Imported",
@@ -2113,6 +2142,21 @@
"monotone_cubic": "Smooth",
"linear": "Linear"
},
"boundingBoxes": {
"clearAll": "Clear all",
"clickRegionToEdit": "Click a region to edit it.",
"typeObj": "obj",
"typeText": "text",
"textLabel": "Text",
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette"
},
"palette": {
"addColor": "Add a color",
"swatchTitle": "Click edit · drag reorder · right-click remove"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
@@ -2189,6 +2233,11 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"dynamicGroup": {
"addRow": "Add row",
"removeRow": "Remove row",
"row": "Row {index}"
},
"oauth": {
"consent": {
"allow": "Continue",
@@ -2946,7 +2995,7 @@
"share": "Share"
},
"shortcuts": {
"shortcuts": "Shortcuts",
"shortcuts": "Short\u00adcuts",
"essentials": "Essential",
"viewControls": "View Controls",
"manageShortcuts": "Manage Shortcuts",

View File

@@ -1,6 +1,7 @@
<template>
<BaseModalLayout
v-model:right-panel-open="isRightPanelOpen"
data-testid="asset-browser-modal"
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="displayTitle"

View File

@@ -1,5 +1,6 @@
<template>
<div
data-testid="asset-card"
data-component-id="AssetCard"
:data-asset-id="asset.id"
:aria-labelledby="titleId"

View File

@@ -10,6 +10,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
import { useMediaAssetActions } from './useMediaAssetActions'
// Use vi.hoisted to create a mutable reference for isCloud
@@ -145,6 +146,17 @@ vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: mockGetOutputAssetMetadata
}))
const mockResolveOutputAssetItems = vi.hoisted(() =>
vi.fn().mockResolvedValue([])
)
vi.mock('../utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal<typeof outputAssetUtilModule>()
return {
...actual,
resolveOutputAssetItems: mockResolveOutputAssetItems
}
})
const mockDeleteAsset = vi.hoisted(() => vi.fn())
const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
@@ -283,6 +295,8 @@ describe('useMediaAssetActions', () => {
mockGetOutputAssetMetadata.mockReset()
mockGetOutputAssetMetadata.mockReturnValue(null)
mockGetAssetType.mockReset()
mockResolveOutputAssetItems.mockReset()
mockResolveOutputAssetItems.mockResolvedValue([])
})
describe('addWorkflow', () => {
@@ -582,6 +596,236 @@ describe('useMediaAssetActions', () => {
})
})
describe('downloadAssets - OSS multi-output expansion', () => {
beforeEach(() => {
mockIsCloud.value = false
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,
previewUrl?: string
): AssetItem {
return createMockAsset({
id,
name,
tags: ['output'],
preview_url: previewUrl ?? `https://example.com/${name}`,
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
})
}
it('expands a grouped asset into individual downloads', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockResolvedValueOnce([
createOutputAsset('g1-out1', 'out1.png', 'job1'),
createOutputAsset('g1-out2', 'out2.png', 'job1'),
createOutputAsset('g1-out3', 'out3.png', 'job1')
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job1', outputCount: 3 }),
expect.objectContaining({ createdAt: expect.any(String) })
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
1,
'https://example.com/out1.png',
'out1.png'
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
2,
'https://example.com/out2.png',
'out2.png'
)
expect(mockDownloadFile).toHaveBeenNthCalledWith(
3,
'https://example.com/out3.png',
'out3.png'
)
expect(mockCreateAssetExport).not.toHaveBeenCalled()
})
it('mixes grouped and single-output assets in one selection', async () => {
const grouped = createOutputAsset('g1', 'cover.png', 'job1', 2)
const single = createOutputAsset('s1', 'solo.png', 'job2')
mockResolveOutputAssetItems.mockResolvedValueOnce([
createOutputAsset('g1-a', 'a.png', 'job1'),
createOutputAsset('g1-b', 'b.png', 'job1')
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped, single])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['a.png', 'b.png', 'solo.png'])
})
it('falls back to the original asset when resolveOutputAssetItems returns empty', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockResolvedValueOnce([])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/cover.png',
'cover.png'
)
})
it('does not call resolveOutputAssetItems when no grouped assets are selected', () => {
const single1 = createOutputAsset(
's1',
'a.png',
'job1',
undefined,
'https://example.com/a.png'
)
const single2 = createOutputAsset(
's2',
'b.png',
'job2',
1,
'https://example.com/b.png'
)
const actions = useMediaAssetActions()
actions.downloadAssets([single1, single2])
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
})
it('deduplicates downloads when an expanded child is also selected alongside its parent', async () => {
const grouped = createOutputAsset('job1-cover', 'cover.png', 'job1', 3)
const child = createMockAsset({
id: 'job1-child-a',
name: 'out1.png',
tags: ['output'],
preview_url: 'https://example.com/out1.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
})
mockResolveOutputAssetItems.mockResolvedValueOnce([
createMockAsset({
id: 'job1-child-a',
name: 'out1.png',
tags: ['output'],
preview_url: 'https://example.com/out1.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
}),
createMockAsset({
id: 'job1-child-b',
name: 'out2.png',
tags: ['output'],
preview_url: 'https://example.com/out2.png',
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
})
])
const actions = useMediaAssetActions()
actions.downloadAssets([grouped, child])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
})
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['out1.png', 'out2.png'])
})
it('falls back to the preview download when resolveOutputAssetItems rejects', async () => {
const grouped = createOutputAsset(
'g1',
'cover.png',
'job1',
3,
'https://example.com/cover.png'
)
mockResolveOutputAssetItems.mockRejectedValueOnce(new Error('boom'))
const actions = useMediaAssetActions()
actions.downloadAssets([grouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).toHaveBeenCalledWith(
'https://example.com/cover.png',
'cover.png'
)
})
it('still downloads resolvable assets when one grouped asset fails to expand', async () => {
const failingGrouped = createOutputAsset(
'g1',
'cover1.png',
'job1',
3,
'https://example.com/cover1.png'
)
const okGrouped = createOutputAsset('g2', 'cover2.png', 'job2', 2)
mockResolveOutputAssetItems.mockImplementation(
(metadata: { jobId: string }) => {
if (metadata.jobId === 'job1') {
return Promise.reject(new Error('job1 lookup failed'))
}
return Promise.resolve([
createOutputAsset('g2-a', 'out2a.png', 'job2'),
createOutputAsset('g2-b', 'out2b.png', 'job2')
])
}
)
const actions = useMediaAssetActions()
actions.downloadAssets([failingGrouped, okGrouped])
await vi.waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
})
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
expect(filenames).toEqual(['cover1.png', 'out2a.png', 'out2b.png'])
})
})
describe('downloadAssets - cloud zip filters', () => {
beforeEach(() => {
mockIsCloud.value = true

View File

@@ -27,7 +27,10 @@ import { getAssetUrl } from '../utils/assetUrlUtil'
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
import { getAssetOutputCount } from '../utils/outputAssetUtil'
import {
getAssetOutputCount,
resolveOutputAssetItems
} from '../utils/outputAssetUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { isResultItemType } from '@/utils/typeGuardUtil'
@@ -109,8 +112,9 @@ export function useMediaAssetActions() {
* Download one or more assets.
* In cloud mode, creates a ZIP export via the backend when called with
* 2+ assets or with any asset whose job has `outputCount > 1`.
* Falls back to direct downloads in OSS mode and for single single-output
* assets. With no argument, uses the asset from `MediaAssetKey` context.
* In OSS mode, downloads each file directly, expanding grouped assets
* (`outputCount > 1`) into their individual outputs.
* With no argument, uses the asset from `MediaAssetKey` context.
*/
const downloadAssets = (assets?: AssetItem[]) => {
const targetAssets =
@@ -127,13 +131,13 @@ export function useMediaAssetActions() {
return
}
try {
targetAssets.forEach((asset) => {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
if (hasMultiOutputJobs) {
void downloadAssetsIndividually(targetAssets)
return
}
try {
targetAssets.forEach((asset) => downloadSingleAsset(asset))
toast.add({
severity: 'success',
summary: t('g.success'),
@@ -150,6 +154,66 @@ export function useMediaAssetActions() {
}
}
function downloadSingleAsset(asset: AssetItem) {
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
}
async function expandAssetForDownload(
asset: AssetItem
): Promise<AssetItem[]> {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (
!metadata ||
typeof metadata.outputCount !== 'number' ||
metadata.outputCount <= 1
) {
return [asset]
}
try {
const resolved = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
return resolved.length > 0 ? resolved : [asset]
} catch (error) {
console.error('Failed to expand grouped asset for download:', error)
return [asset]
}
}
async function downloadAssetsIndividually(assets: AssetItem[]) {
try {
const expanded = await Promise.all(assets.map(expandAssetForDownload))
const seenAssetIds = new Set<string>()
const filesToDownload = expanded.flat().filter((asset) => {
if (seenAssetIds.has(asset.id)) return false
seenAssetIds.add(asset.id)
return true
})
filesToDownload.forEach((asset) => downloadSingleAsset(asset))
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t(
'mediaAsset.selection.downloadsStarted',
filesToDownload.length
),
life: 2000
})
} catch (error) {
console.error('Failed to download assets:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
}
async function downloadAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()

View File

@@ -0,0 +1,169 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createAssetWidget } from './createAssetWidget'
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
const show = vi.fn()
const browse = vi.fn()
return {
useAssetBrowserDialog: () => ({ show, browse })
}
})
interface HostAssetWidget extends IBaseWidget<
string,
'asset',
IWidgetAssetOptions
> {
node: LGraphNode
}
type OnWidgetChanged = NonNullable<LGraphNode['onWidgetChanged']>
function checkpointAsset(name: string): AssetItem {
return {
id: `asset-${name}`,
name,
hash: 'checkpoint-hash',
mime_type: 'application/octet-stream',
tags: []
}
}
function createAssetWidgetNode() {
const node = new LGraphNode('TestNode')
const onWidgetChanged = vi.fn<OnWidgetChanged>()
node.onWidgetChanged = onWidgetChanged
return { node, onWidgetChanged }
}
function assertAssetOptions(
options: unknown
): asserts options is IWidgetAssetOptions {
if (!options || typeof options !== 'object') {
throw new Error('Expected asset widget options')
}
if (!('openModal' in options) || typeof options.openModal !== 'function') {
throw new Error('Expected asset widget options')
}
}
function firstShowOptions() {
const showOptions = vi.mocked(useAssetBrowserDialog().show).mock.calls[0]?.[0]
if (!showOptions) {
throw new Error('Expected the asset browser dialog to open')
}
return showOptions
}
describe('createAssetWidget', () => {
let captureCanvasState: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.resetAllMocks()
captureCanvasState = vi.fn()
setActivePinia(
createTestingPinia({
stubActions: false,
initialState: {
workflow: {
activeWorkflow: {
changeTracker: { captureCanvasState }
}
}
}
})
)
})
it('preserves regular asset widget change handling for the owning widget', async () => {
const { node, onWidgetChanged } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
inputNameForBrowser: 'ckpt_name',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
const showOptions = firstShowOptions()
expect(showOptions).toMatchObject({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: 'fake_model.safetensors'
})
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
expect(widget.value).toBe('real_model.safetensors')
expect(onWidgetChanged).toHaveBeenCalledWith(
'ckpt_name',
'real_model.safetensors',
'fake_model.safetensors',
widget
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('commits cloned asset modal selections through the promoted host widget', async () => {
const { node, onWidgetChanged: sourceOnWidgetChanged } =
createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
inputNameForBrowser: 'ckpt_name',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(sourceWidget.options)
const hostCallback = vi.fn<NonNullable<IBaseWidget['callback']>>()
const hostNode = new LGraphNode('PromotedHostNode')
const hostOnWidgetChanged = vi.fn<OnWidgetChanged>()
hostNode.onWidgetChanged = hostOnWidgetChanged
const hostWidget: HostAssetWidget = {
type: 'asset',
name: 'host_ckpt_name',
value: 'fake_model.safetensors',
callback: hostCallback,
options: sourceWidget.options,
node: hostNode,
y: 0
}
await sourceWidget.options.openModal(hostWidget)
const showOptions = firstShowOptions()
expect(showOptions).toMatchObject({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: 'fake_model.safetensors'
})
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
expect(sourceOnWidgetChanged).not.toHaveBeenCalled()
expect(hostWidget.value).toBe('real_model.safetensors')
expect(hostCallback).toHaveBeenCalledWith('real_model.safetensors')
expect(hostOnWidgetChanged).toHaveBeenCalledWith(
'host_ckpt_name',
'real_model.safetensors',
'fake_model.safetensors',
hostWidget
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
})

View File

@@ -12,8 +12,11 @@ import {
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
type WidgetWithNode = IBaseWidget & { node: LGraphNode }
interface CreateAssetWidgetParams {
/** The node to add the widget to */
node: LGraphNode
@@ -25,34 +28,26 @@ interface CreateAssetWidgetParams {
inputNameForBrowser?: string
/** Default value for the widget */
defaultValue?: string
/** Callback when widget value changes */
onValueChange?: (
widget: IBaseWidget,
newValue: string,
oldValue: unknown
) => void
}
/**
* Creates an asset widget that opens the Asset Browser dialog for model selection.
* Used by both regular nodes (via useComboWidget) and PrimitiveNode.
*
* @param params - Configuration for the asset widget
* @returns The created asset widget
*/
export function createAssetWidget(
params: CreateAssetWidgetParams
): IBaseWidget {
const {
node,
widgetName,
nodeTypeForBrowser,
inputNameForBrowser,
defaultValue,
onValueChange
} = params
interface CreateAssetWidgetOptionsParams {
widgetName: string
nodeTypeForBrowser: string
inputNameForBrowser?: string
}
const displayLabel = defaultValue ?? t('widgets.selectModel')
function hasOwnerNode(widget: IBaseWidget): widget is WidgetWithNode {
return (
'node' in widget && typeof widget.node === 'object' && widget.node !== null
)
}
function createAssetWidgetOptions({
widgetName,
nodeTypeForBrowser,
inputNameForBrowser
}: CreateAssetWidgetOptionsParams): IWidgetAssetOptions {
const inputName = inputNameForBrowser ?? widgetName
const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
@@ -60,8 +55,8 @@ export function createAssetWidget(
await assetBrowserDialog.show({
nodeType: nodeTypeForBrowser,
inputName: inputNameForBrowser ?? widgetName,
currentValue: widget.value as string,
inputName,
currentValue: String(widget.value ?? ''),
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
@@ -98,15 +93,44 @@ export function createAssetWidget(
const oldValue = widget.value
widget.value = validatedFilename.data
onValueChange?.(widget, validatedFilename.data, oldValue)
widget.callback?.(widget.value)
if (hasOwnerNode(widget)) {
widget.node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
if (oldValue !== validatedFilename.data) {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
}
})
}
const options: IWidgetAssetOptions = {
return {
openModal,
nodeType: nodeTypeForBrowser
}
}
export function createAssetWidget(
params: CreateAssetWidgetParams
): IBaseWidget {
const {
node,
widgetName,
nodeTypeForBrowser,
inputNameForBrowser,
defaultValue
} = params
const displayLabel = defaultValue ?? t('widgets.selectModel')
const options = createAssetWidgetOptions({
widgetName,
nodeTypeForBrowser,
inputNameForBrowser
})
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
}

View File

@@ -135,7 +135,6 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
@@ -188,9 +187,7 @@ const signUpWithEmail = async (values: SignUpData) => {
}
onMounted(async () => {
if (isCloud) {
telemetry?.trackSignupOpened()
}
telemetry?.trackSignupOpened()
userIsInChina.value = await isInChina()
})

View File

@@ -18,7 +18,6 @@ import {
getSurveyCompletedStatus,
submitSurvey
} from '@/platform/cloud/onboarding/auth'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { useTelemetry } from '@/platform/telemetry'
@@ -46,9 +45,7 @@ onMounted(async () => {
await router.replace({ name: 'cloud-user-check' })
return
}
if (isCloud) {
useTelemetry()?.trackSurvey('opened')
}
useTelemetry()?.trackSurvey('opened')
} catch (error) {
console.error('Failed to check survey status:', error)
}
@@ -62,9 +59,7 @@ const onSubmitSurvey = async (payload: Record<string, unknown>) => {
isSubmitting.value = true
try {
await submitSurvey(payload)
if (isCloud) {
useTelemetry()?.trackSurvey('submitted', payload)
}
useTelemetry()?.trackSurvey('submitted', payload)
await router.push({ name: 'cloud-user-check' })
} finally {
isSubmitting.value = false

View File

@@ -53,11 +53,9 @@ watch(
)
const handleSubscribe = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
}
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
}

View File

@@ -237,12 +237,10 @@ function useSubscriptionInternal() {
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
if (isCloud) {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
}
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
void showSubscriptionRequiredDialog(options)
}

View File

@@ -38,6 +38,19 @@ vi.mock('@/stores/commandStore', () => ({
})
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackHelpResourceClicked: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
: null
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
@@ -48,6 +61,7 @@ Object.defineProperty(window, 'open', {
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
})
describe('handleAddApiCredits', () => {
@@ -73,6 +87,27 @@ describe('useSubscriptionActions', () => {
expect(isLoadingSupport.value).toBe(false)
})
it('tracks help-resource telemetry when messaging support in cloud', async () => {
const { handleMessageSupport } = useSubscriptionActions()
await handleMessageSupport()
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
})
it('does not fire telemetry when messaging support in OSS builds', async () => {
mockIsCloud.value = false
const { handleMessageSupport } = useSubscriptionActions()
await handleMessageSupport()
expect(mockTrackHelpResourceClicked).not.toHaveBeenCalled()
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
const { handleMessageSupport, isLoadingSupport } =

View File

@@ -2,7 +2,6 @@ import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -30,13 +29,11 @@ export function useSubscriptionActions() {
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
if (isCloud) {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
}
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)

View File

@@ -1,5 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMissingMediaStore } from './missingMediaStore'
import type { MissingMediaCandidate } from './types'
@@ -96,14 +97,6 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaNodeIds.has('2')).toBe(true)
})
it('hasMissingMediaOnNode checks node presence', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('42', 'photo.png')])
expect(store.hasMissingMediaOnNode('42')).toBe(true)
expect(store.hasMissingMediaOnNode('99')).toBe(false)
})
it('removeMissingMediaByWidget removes matching node+widget entry', () => {
const store = useMissingMediaStore()
store.setMissingMedia([

View File

@@ -68,10 +68,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
missingMediaCandidates.value = media.length ? media : null
}
function hasMissingMediaOnNode(nodeLocatorId: string): boolean {
return missingMediaNodeIds.value.has(nodeLocatorId)
}
function isContainerWithMissingMedia(node: LGraphNode): boolean {
return activeMissingMediaGraphIds.value.has(String(node.id))
}
@@ -157,7 +153,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
clearMissingMedia,
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia
}
})

View File

@@ -13,6 +13,7 @@ import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
collectAllNodes,
getExecutionIdByNode
@@ -169,7 +170,7 @@ export function scanNodeModelCandidates(
function scanAssetWidget(
node: { type: string },
widget: IAssetWidget,
executionId: string,
executionId: NodeExecutionId,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
@@ -190,7 +191,7 @@ function scanAssetWidget(
function scanComboWidget(
node: { type: string },
widget: IComboWidget,
executionId: string,
executionId: NodeExecutionId,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {

View File

@@ -1,8 +1,14 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockNodeLocatorIdToNodeExecutionId = vi.hoisted(() =>
vi.fn((nodeLocatorId: string) => nodeLocatorId)
)
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => `translated:${key}`),
st: vi.fn((_key: string, fallback: string) => fallback)
@@ -12,6 +18,12 @@ vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
})
}))
import { useMissingModelStore } from './missingModelStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
@@ -39,6 +51,9 @@ describe('missingModelStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
mockNodeLocatorIdToNodeExecutionId.mockImplementation(
(nodeLocatorId: string) => nodeLocatorId
)
})
describe('setMissingModels', () => {
@@ -146,7 +161,9 @@ describe('missingModelStore', () => {
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('5')).toBe(true)
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 5))).toBe(
true
)
})
it('returns false when node has no missing model', () => {
@@ -155,12 +172,30 @@ describe('missingModelStore', () => {
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('99')).toBe(false)
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 99))).toBe(
false
)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.hasMissingModelOnNode('1')).toBe(false)
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 1))).toBe(
false
)
})
it('compares subgraph locators against missing model execution IDs', () => {
const store = useMissingModelStore()
const locatorId = createNodeLocatorId(
'11111111-1111-1111-1111-111111111111',
63
)
mockNodeLocatorIdToNodeExecutionId.mockReturnValueOnce('65:70:63')
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '65:70:63' })
])
expect(store.hasMissingModelOnNode(locatorId)).toBe(true)
})
})

View File

@@ -6,10 +6,11 @@ import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
/**
@@ -19,6 +20,7 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
*/
export const useMissingModelStore = defineStore('missingModel', () => {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
const isRefreshingMissingModels = ref(false)
@@ -193,8 +195,10 @@ export const useMissingModelStore = defineStore('missingModel', () => {
missingModelCandidates.value = [...existing, ...newModels]
}
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
return missingModelNodeIds.value.has(nodeLocatorId)
function hasMissingModelOnNode(nodeLocatorId: NodeLocatorId): boolean {
const executionId =
workflowStore.nodeLocatorIdToNodeExecutionId(nodeLocatorId)
return executionId ? missingModelNodeIds.value.has(executionId) : false
}
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {

View File

@@ -53,6 +53,7 @@ import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
function mockNode(
id: number,
@@ -138,7 +139,9 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
it('uses executionId when available for nodeId', () => {
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'Missing')])
vi.mocked(getExecutionIdByNode).mockReturnValue('exec-42')
vi.mocked(getExecutionIdByNode).mockReturnValue(
createNodeExecutionId(['exec-42'])
)
rescanAndSurfaceMissingNodes(mockGraph())

View File

@@ -12,7 +12,7 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
:autofocus="activeCategoryKey !== 'keybinding'"
@search="handleSearch"
/>
</div>

View File

@@ -17,6 +17,7 @@ import { useWorkflowDraftStoreV2 } from '@/platform/workflow/persistence/stores/
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraph } from '@/utils/typeGuardUtil'
import {
createMockCanvas,
@@ -887,81 +888,75 @@ describe('useWorkflowStore', () => {
})
})
describe('nodeExecutionIdToNodeLocatorId', () => {
it('should convert execution ID to NodeLocatorId', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
describe('executionIdToCurrentId', () => {
it('should convert an execution ID to the active subgraph node ID', () => {
const result = store.executionIdToCurrentId('123:456')
expect(result).toBe('456')
})
it('should return simple node ID for root level nodes', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123')
expect(result).toBe('123')
it('should return undefined for execution IDs outside the active subgraph', () => {
expect(() => store.executionIdToCurrentId('999:456')).not.toThrow()
expect(store.executionIdToCurrentId('999:456')).toBeUndefined()
})
it('should return null for invalid execution IDs', () => {
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
expect(result).toBeNull()
it('should return undefined for malformed execution IDs', () => {
expect(() => store.executionIdToCurrentId('123::456')).not.toThrow()
expect(store.executionIdToCurrentId('123::456')).toBeUndefined()
})
})
describe('nodeLocatorIdToNodeId', () => {
it('should extract node ID from NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
)
expect(result).toBe(456)
})
it('should handle string node IDs', () => {
const result = store.nodeLocatorIdToNodeId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'node_1')
)
expect(result).toBe('node_1')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeId('123')
const result = store.nodeLocatorIdToNodeId(
createNodeLocatorId(null, 123)
)
expect(result).toBe(123)
const stringResult = store.nodeLocatorIdToNodeId('node_1')
const stringResult = store.nodeLocatorIdToNodeId(
createNodeLocatorId(null, 'node_1')
)
expect(stringResult).toBe('node_1')
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId('invalid:format')
expect(result).toBeNull()
})
})
describe('nodeLocatorIdToNodeExecutionId', () => {
it('should convert NodeLocatorId to execution ID', () => {
// Need to mock isSubgraph to identify our mockSubgraph
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
return obj === store.activeSubgraph
})
const result = store.nodeLocatorIdToNodeExecutionId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
)
expect(result).toBe('123:456')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeExecutionId('123')
const result = store.nodeLocatorIdToNodeExecutionId(
createNodeLocatorId(null, 123)
)
expect(result).toBe('123')
})
it('should return null for unknown subgraph UUID', () => {
const result = store.nodeLocatorIdToNodeExecutionId(
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
createNodeLocatorId('unknown-uuid-1234-5678-90ab-cdef12345678', 456)
)
expect(result).toBeNull()
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
expect(result).toBeNull()
})
})
})

View File

@@ -83,12 +83,9 @@ interface WorkflowStore {
executionIdToCurrentId: (id: string) => string | undefined
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId) => NodeId
nodeLocatorIdToNodeExecutionId: (
locatorId: NodeLocatorId | string,
locatorId: NodeLocatorId,
targetSubgraph?: Subgraph
) => NodeExecutionId | null
}
@@ -580,17 +577,16 @@ export const useWorkflowStore = defineStore('workflow', () => {
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
subgraphNodeIds: string[]
): Subgraph[] | undefined => {
const [currentPart, ...remainingParts] = subgraphNodeIds
if (currentPart === undefined) return []
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (subgraph === undefined) throw new Error('Subgraph not found')
if (subgraph === undefined) return
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
const childSubgraphs = getSubgraphsFromInstanceIds(subgraph, remainingParts)
return childSubgraphs ? [subgraph, ...childSubgraphs] : undefined
}
//FIXME: use existing util function
@@ -604,17 +600,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
return
}
// Parse the execution ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
const executionPath = parseNodeExecutionId(id)?.map(String)
if (!executionPath) return
// Start from the root graph
const graph = comfyApp.rootGraph
const nodeId = executionPath.at(-1)
if (nodeId === undefined) return
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.rootGraph,
executionPath.slice(0, -1)
)
if (subgraphs?.at(-1) === subgraph) return nodeId
}
watch(activeWorkflow, updateActiveGraph)
@@ -632,7 +628,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
const targetSubgraph = subgraph ?? activeSubgraph.value
if (!targetSubgraph) {
// Node is in the root graph, return the node ID as-is
return String(nodeId)
return createNodeLocatorId(null, nodeId)
}
return createNodeLocatorId(targetSubgraph.id, nodeId)
@@ -646,55 +642,16 @@ export const useWorkflowStore = defineStore('workflow', () => {
const nodeToNodeLocatorId = (node: LGraphNode): NodeLocatorId => {
if (isSubgraph(node.graph))
return createNodeLocatorId(node.graph.id, node.id)
return String(node.id)
}
/**
* Convert an execution ID to a NodeLocatorId
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
* @returns The NodeLocatorId or null if conversion fails
*/
const nodeExecutionIdToNodeLocatorId = (
nodeExecutionId: NodeExecutionId | string
): NodeLocatorId | null => {
// Handle simple node IDs (root graph - no colons)
if (!nodeExecutionId.includes(':')) {
return nodeExecutionId
}
const parts = parseNodeExecutionId(nodeExecutionId)
if (!parts || parts.length === 0) return null
const nodeId = parts[parts.length - 1]
const subgraphNodeIds = parts.slice(0, -1)
if (subgraphNodeIds.length === 0) {
// Node is in root graph, return the node ID as-is
return String(nodeId)
}
try {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.rootGraph,
subgraphNodeIds.map((id) => String(id))
)
const immediateSubgraph = subgraphs[subgraphs.length - 1]
return createNodeLocatorId(immediateSubgraph.id, nodeId)
} catch {
return null
}
return createNodeLocatorId(null, node.id)
}
/**
* Extract the node ID from a NodeLocatorId
* @param locatorId The NodeLocatorId
* @returns The local node ID or null if invalid
* @returns The local node ID
*/
const nodeLocatorIdToNodeId = (
locatorId: NodeLocatorId | string
): NodeId | null => {
const parsed = parseNodeLocatorId(locatorId)
return parsed?.localNodeId ?? null
const nodeLocatorIdToNodeId = (locatorId: NodeLocatorId): NodeId => {
return parseNodeLocatorId(locatorId)!.localNodeId
}
/**
@@ -704,7 +661,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
* @returns The execution ID or null if the node is not accessible from the target context
*/
const nodeLocatorIdToNodeExecutionId = (
locatorId: NodeLocatorId | string,
locatorId: NodeLocatorId,
targetSubgraph?: Subgraph
): NodeExecutionId | null => {
const parsed = parseNodeLocatorId(locatorId)
@@ -714,7 +671,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
// If no subgraph UUID, this is a root graph node
if (!subgraphUuid) {
return String(localNodeId)
return createNodeExecutionId([localNodeId])
}
// Find the path from root to the subgraph with this UUID
@@ -751,7 +708,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
comfyApp.rootGraph,
path.slice(0, idx + 1).map((id) => String(id))
)
return subgraphs[subgraphs.length - 1] === targetSubgraph
return subgraphs?.at(-1) === targetSubgraph
})
) {
return null
@@ -795,7 +752,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
}

View File

@@ -49,6 +49,17 @@ vi.mock('@/stores/dialogStore', () => ({
}))
}))
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackTemplate } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackTemplate: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value ? { trackTemplate: mockTrackTemplate } : null
}))
// Mock fetch
global.fetch = vi.fn()
@@ -58,6 +69,9 @@ describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
beforeEach(() => {
mockIsCloud.value = true
mockTrackTemplate.mockClear()
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
@@ -285,6 +299,30 @@ describe('useTemplateWorkflows', () => {
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})
it('tracks template telemetry on load in cloud builds', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
mockWorkflowTemplatesStore.isLoaded = true
await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(mockTrackTemplate).toHaveBeenCalledWith({
workflow_name: 'template1',
template_source: 'default'
})
})
it('does not fire template telemetry in OSS builds', async () => {
mockIsCloud.value = false
const { loadWorkflowTemplate } = useTemplateWorkflows()
mockWorkflowTemplatesStore.isLoaded = true
await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(mockTrackTemplate).not.toHaveBeenCalled()
})
it('should handle errors when loading templates', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()

View File

@@ -1,7 +1,6 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
@@ -132,12 +131,10 @@ export function useTemplateWorkflows() {
? t(`templateWorkflows.template.${id}`, id)
: id
if (isCloud) {
useTelemetry()?.trackTemplate({
workflow_name: id,
template_source: sourceModule
})
}
useTelemetry()?.trackTemplate({
workflow_name: id,
template_source: sourceModule
})
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName, {

View File

@@ -1,7 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -85,6 +88,42 @@ describe('useCanvasStore', () => {
expect(originalHandler).toHaveBeenCalledWith(2.0, app.canvas.ds.offset)
})
})
describe('node:before-removed selection cleanup', () => {
it('removes the node from store.selectedItems before its onRemoved fires', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
graph.add(node)
const selectedItems = new Set<Positionable>([node])
const fakeCanvas = {
canvas: document.createElement('canvas'),
graph,
selectedItems,
deselect: vi.fn((item: Positionable) => {
selectedItems.delete(item)
})
}
store.canvas = fakeCanvas as unknown as LGraphCanvas
await nextTick()
store.updateSelectedItems()
expect(store.selectedItems).toContain(node)
let stillSelectedInOnRemoved: boolean | undefined
node.onRemoved = () => {
stillSelectedInOnRemoved = store.selectedItems.includes(node)
}
graph.remove(node)
expect(
stillSelectedInOnRemoved,
'selectedItems must not contain the node when onRemoved fires'
).toBe(false)
expect(store.selectedItems).toEqual([])
})
})
it('Does not include groups in selected nodeIds', async () => {
store.selectedItems = [new LGraphGroup()]

View File

@@ -131,6 +131,18 @@ export const useCanvasStore = defineStore('canvas', () => {
whenever(
() => canvas.value,
(newCanvas) => {
currentGraph.value = newCanvas.graph
// Scoped to the on-screen graph: selection only holds items from it,
// so removals in other graphs can't affect the live selection.
useEventListener(
() => currentGraph.value?.events,
'node:before-removed',
(e: CustomEvent<{ node: LGraphNode }>) => {
newCanvas.deselect(e.detail.node)
updateSelectedItems()
}
)
useEventListener(
newCanvas.canvas,
'litegraph:set-graph',

View File

@@ -21,7 +21,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
useTransformState: () => ({
camera: computed(() => mockData.mockCamera),
transformStyle: computed(() => mockData.mockTransformStyle),
canvasToScreen: vi.fn(),
screenToCanvas: vi.fn(),
isNodeInViewport: vi.fn(),
syncWithCanvas
@@ -180,7 +179,6 @@ describe('TransformPane', () => {
const transformState = useTransformState()
expect(transformState.syncWithCanvas).toBeDefined()
expect(transformState.canvasToScreen).toBeDefined()
expect(transformState.screenToCanvas).toBeDefined()
})
})

View File

@@ -1,3 +1,5 @@
import type { NodeId } from '@/renderer/core/layout/types'
/**
* Slot identifier utilities for consistent slot key generation and parsing
*
@@ -7,7 +9,7 @@
*/
interface SlotIdentifier {
nodeId: string
nodeId: NodeId
index: number
isInput: boolean
}
@@ -18,23 +20,23 @@ interface SlotIdentifier {
*/
export function getSlotKey(identifier: SlotIdentifier): string
export function getSlotKey(
nodeId: string,
nodeId: NodeId,
index: number,
isInput: boolean
): string
export function getSlotKey(
nodeIdOrIdentifier: string | SlotIdentifier,
nodeIdOrIdentifier: NodeId | SlotIdentifier,
index?: number,
isInput?: boolean
): string {
if (typeof nodeIdOrIdentifier === 'object') {
const { nodeId, index, isInput } = nodeIdOrIdentifier
return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
return `${String(nodeId)}-${isInput ? 'in' : 'out'}-${index}`
}
if (index === undefined || isInput === undefined) {
throw new Error('Missing required parameters for slot key generation')
}
return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
return `${String(nodeIdOrIdentifier)}-${isInput ? 'in' : 'out'}-${index}`
}

View File

@@ -127,8 +127,8 @@ class LayoutStoreImpl implements LayoutStore {
private isGlobalDispatchQueued = false
// CustomRef cache and trigger functions
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
private nodeRefs = new Map<string, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<string, () => void>()
// New data structures for hit testing
private linkLayouts = new Map<LinkId, LinkLayout>()
@@ -137,10 +137,10 @@ class LayoutStoreImpl implements LayoutStore {
private rerouteLayouts = new Map<RerouteId, RerouteLayout>()
// Spatial index managers
private spatialIndex: SpatialIndexManager // For nodes
private linkSegmentSpatialIndex: SpatialIndexManager // For link segments (single index for all link geometry)
private slotSpatialIndex: SpatialIndexManager // For slots
private rerouteSpatialIndex: SpatialIndexManager // For reroutes
private spatialIndex: SpatialIndexManager<NodeId> // For nodes
private linkSegmentSpatialIndex: SpatialIndexManager<string> // For link segments (single index for all link geometry)
private slotSpatialIndex: SpatialIndexManager<string> // For slots
private rerouteSpatialIndex: SpatialIndexManager<string> // For reroutes
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
@@ -173,10 +173,10 @@ class LayoutStoreImpl implements LayoutStore {
this.yoperations = this.ydoc.getArray('operations')
// Initialize spatial index managers
this.spatialIndex = new SpatialIndexManager()
this.linkSegmentSpatialIndex = new SpatialIndexManager() // Single index for all link geometry
this.slotSpatialIndex = new SpatialIndexManager()
this.rerouteSpatialIndex = new SpatialIndexManager()
this.spatialIndex = new SpatialIndexManager<NodeId>()
this.linkSegmentSpatialIndex = new SpatialIndexManager<string>() // Single index for all link geometry
this.slotSpatialIndex = new SpatialIndexManager<string>()
this.rerouteSpatialIndex = new SpatialIndexManager<string>()
// Listen for Yjs changes and trigger Vue reactivity
this.ynodes.observe((event: Y.YMapEvent<NodeLayoutMap>) => {
@@ -230,24 +230,24 @@ class LayoutStoreImpl implements LayoutStore {
* Get or create a customRef for a node layout
*/
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null> {
let nodeRef = this.nodeRefs.get(nodeId)
const nodeKey = String(nodeId)
let nodeRef = this.nodeRefs.get(nodeKey)
if (!nodeRef) {
nodeRef = customRef<NodeLayout | null>((track, trigger) => {
// Store the trigger so we can call it when Yjs changes
this.nodeTriggers.set(nodeId, trigger)
this.nodeTriggers.set(nodeKey, trigger)
return {
get: () => {
track()
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(nodeKey)
const layout = ynode ? yNodeToLayout(ynode) : null
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeId)
const existing = this.ynodes.get(nodeKey)
if (existing) {
this.applyOperation({
type: 'deleteNode',
@@ -261,7 +261,7 @@ class LayoutStoreImpl implements LayoutStore {
}
} else {
// Update operation - detect what changed
const existing = this.ynodes.get(nodeId)
const existing = this.ynodes.get(nodeKey)
if (!existing) {
// Create operation
this.applyOperation({
@@ -326,7 +326,7 @@ class LayoutStoreImpl implements LayoutStore {
}
})
this.nodeRefs.set(nodeId, nodeRef)
this.nodeRefs.set(nodeKey, nodeRef)
}
return nodeRef
@@ -341,13 +341,10 @@ class LayoutStoreImpl implements LayoutStore {
void this.version
const result: NodeId[] = []
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = yNodeToLayout(ynode)
if (layout && boundsIntersect(layout.bounds, bounds)) {
result.push(nodeId)
}
for (const [nodeId, ynode] of this.ynodes) {
const layout = yNodeToLayout(ynode)
if (boundsIntersect(layout.bounds, bounds)) {
result.push(nodeId)
}
}
return result
@@ -363,14 +360,9 @@ class LayoutStoreImpl implements LayoutStore {
void this.version
const result = new Map<NodeId, NodeLayout>()
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = yNodeToLayout(ynode)
if (layout) {
result.set(nodeId, layout)
}
}
for (const [nodeId, ynode] of this.ynodes) {
const layout = yNodeToLayout(ynode)
result.set(nodeId, layout)
}
return result
})
@@ -389,14 +381,9 @@ class LayoutStoreImpl implements LayoutStore {
queryNodeAtPoint(point: Point): NodeId | null {
const nodes: Array<[NodeId, NodeLayout]> = []
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = yNodeToLayout(ynode)
if (layout) {
nodes.push([nodeId, layout])
}
}
for (const [nodeId, ynode] of this.ynodes) {
const layout = yNodeToLayout(ynode)
nodes.push([nodeId, layout])
}
// Sort by zIndex (top to bottom)
@@ -446,17 +433,7 @@ class LayoutStoreImpl implements LayoutStore {
deleteLinkLayout(linkId: LinkId): void {
const deleted = this.linkLayouts.delete(linkId)
if (deleted) {
// Clean up any segment layouts for this link
const keysToDelete: string[] = []
for (const [key] of this.linkSegmentLayouts) {
if (key.startsWith(`${linkId}:`)) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
this.linkSegmentLayouts.delete(key)
this.linkSegmentSpatialIndex.remove(key)
}
this.cleanupLinkSegments(linkId)
}
}
@@ -536,6 +513,7 @@ class LayoutStoreImpl implements LayoutStore {
* Update reroute layout data
*/
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void {
const rerouteKey = String(rerouteId)
const existing = this.rerouteLayouts.get(rerouteId)
if (!existing) {
@@ -548,10 +526,10 @@ class LayoutStoreImpl implements LayoutStore {
if (existing) {
// Update spatial index
this.rerouteSpatialIndex.update(String(rerouteId), layout.bounds) // Spatial index uses strings
this.rerouteSpatialIndex.update(rerouteKey, layout.bounds)
} else {
// Insert into spatial index
this.rerouteSpatialIndex.insert(String(rerouteId), layout.bounds) // Spatial index uses strings
this.rerouteSpatialIndex.insert(rerouteKey, layout.bounds)
}
this.rerouteLayouts.set(rerouteId, layout)
@@ -564,7 +542,8 @@ class LayoutStoreImpl implements LayoutStore {
const deleted = this.rerouteLayouts.delete(rerouteId)
if (deleted) {
// Remove from spatial index
this.rerouteSpatialIndex.remove(String(rerouteId)) // Spatial index uses strings
const rerouteKey = String(rerouteId)
this.rerouteSpatialIndex.remove(rerouteKey)
}
}
@@ -917,7 +896,7 @@ class LayoutStoreImpl implements LayoutStore {
// Manually trigger affected node refs after transaction
// This is needed because Yjs observers don't fire for property changes
change.nodeIds.forEach((nodeId) => {
const trigger = this.nodeTriggers.get(nodeId)
const trigger = this.nodeTriggers.get(String(nodeId))
if (trigger) {
trigger()
}
@@ -989,8 +968,9 @@ class LayoutStoreImpl implements LayoutStore {
* This should be called from the component's onUnmounted hook.
*/
cleanupNodeRef(nodeId: NodeId): void {
this.nodeRefs.delete(nodeId)
this.nodeTriggers.delete(nodeId)
const nodeKey = String(nodeId)
this.nodeRefs.delete(nodeKey)
this.nodeTriggers.delete(nodeKey)
}
/**
@@ -1018,8 +998,9 @@ class LayoutStoreImpl implements LayoutStore {
this.isGlobalDispatchQueued = false
nodes.forEach((node, index) => {
const nodeId = String(node.id)
const layout: NodeLayout = {
id: node.id.toString(),
id: nodeId,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: index,
@@ -1032,10 +1013,10 @@ class LayoutStoreImpl implements LayoutStore {
}
}
this.ynodes.set(layout.id, layoutToYNode(layout))
this.ynodes.set(nodeId, layoutToYNode(layout))
// Add to spatial index
this.spatialIndex.insert(layout.id, layout.bounds)
this.spatialIndex.insert(nodeId, layout.bounds)
})
// Trigger all existing refs to notify Vue of the new data
@@ -1048,7 +1029,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: MoveNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) {
return
}
@@ -1076,7 +1057,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: ResizeNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) return
const position = yNodeToLayout(ynode).position
@@ -1102,7 +1083,7 @@ class LayoutStoreImpl implements LayoutStore {
operation: SetNodeZIndexOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
const ynode = this.ynodes.get(String(operation.nodeId))
if (!ynode) return
ynode.set('zIndex', operation.zIndex)
@@ -1114,7 +1095,7 @@ class LayoutStoreImpl implements LayoutStore {
change: LayoutChange
): void {
const ynode = layoutToYNode(operation.layout)
this.ynodes.set(operation.nodeId, ynode)
this.ynodes.set(String(operation.nodeId), ynode)
// Add to spatial index
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
@@ -1127,9 +1108,10 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteNodeOperation,
change: LayoutChange
): void {
if (!this.ynodes.has(operation.nodeId)) return
const nodeKey = String(operation.nodeId)
if (!this.ynodes.has(nodeKey)) return
this.ynodes.delete(operation.nodeId)
this.ynodes.delete(nodeKey)
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
// During undo/redo, Vue components may still hold references to the old ref.
// If we delete the trigger, Vue won't be notified when the node is re-created.
@@ -1143,7 +1125,8 @@ class LayoutStoreImpl implements LayoutStore {
// Delete the associated links
for (const linkId of linksToDelete) {
this.ylinks.delete(String(linkId))
const linkKey = String(linkId)
this.ylinks.delete(linkKey)
this.linkLayouts.delete(linkId)
// Clean up link segment layouts
@@ -1162,7 +1145,7 @@ class LayoutStoreImpl implements LayoutStore {
for (const nodeId of operation.nodeIds) {
const data = operation.bounds[nodeId]
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
if (!ynode || !data) continue
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
@@ -1197,7 +1180,8 @@ class LayoutStoreImpl implements LayoutStore {
linkData.set('targetNodeId', operation.targetNodeId)
linkData.set('targetSlot', operation.targetSlot)
this.ylinks.set(String(operation.linkId), linkData)
const linkKey = String(operation.linkId)
this.ylinks.set(linkKey, linkData)
// Link geometry will be computed separately when nodes move
// This just tracks that the link exists
@@ -1208,9 +1192,10 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteLinkOperation,
change: LayoutChange
): void {
if (!this.ylinks.has(String(operation.linkId))) return
const linkKey = String(operation.linkId)
if (!this.ylinks.has(linkKey)) return
this.ylinks.delete(String(operation.linkId))
this.ylinks.delete(linkKey)
this.linkLayouts.delete(operation.linkId)
// Clean up any segment layouts for this link
this.cleanupLinkSegments(operation.linkId)
@@ -1228,7 +1213,8 @@ class LayoutStoreImpl implements LayoutStore {
rerouteData.set('parentId', operation.parentId)
rerouteData.set('linkIds', operation.linkIds)
this.yreroutes.set(String(operation.rerouteId), rerouteData) // Yjs Map keys must be strings
const rerouteKey = String(operation.rerouteId)
this.yreroutes.set(rerouteKey, rerouteData)
// The observer will automatically update the spatial index
change.type = 'create'
@@ -1238,11 +1224,12 @@ class LayoutStoreImpl implements LayoutStore {
operation: DeleteRerouteOperation,
change: LayoutChange
): void {
if (!this.yreroutes.has(String(operation.rerouteId))) return // Yjs Map keys are strings
const rerouteKey = String(operation.rerouteId)
if (!this.yreroutes.has(rerouteKey)) return
this.yreroutes.delete(String(operation.rerouteId)) // Yjs Map keys are strings
this.yreroutes.delete(rerouteKey)
this.rerouteLayouts.delete(operation.rerouteId) // Layout map uses numeric ID
this.rerouteSpatialIndex.remove(String(operation.rerouteId)) // Spatial index uses strings
this.rerouteSpatialIndex.remove(rerouteKey)
change.type = 'delete'
}
@@ -1251,7 +1238,8 @@ class LayoutStoreImpl implements LayoutStore {
operation: MoveRerouteOperation,
change: LayoutChange
): void {
const yreroute = this.yreroutes.get(String(operation.rerouteId)) // Yjs Map keys are strings
const rerouteKey = String(operation.rerouteId)
const yreroute = this.yreroutes.get(rerouteKey)
if (!yreroute) return
yreroute.set('position', operation.position)
@@ -1331,9 +1319,10 @@ class LayoutStoreImpl implements LayoutStore {
* Clean up all segment layouts for a link
*/
private cleanupLinkSegments(linkId: LinkId): void {
const linkPrefix = `${linkId}:`
const keysToDelete: string[] = []
for (const [key] of this.linkSegmentLayouts) {
if (key.startsWith(`${linkId}:`)) {
if (key.startsWith(linkPrefix)) {
keysToDelete.push(key)
}
}
@@ -1364,15 +1353,17 @@ class LayoutStoreImpl implements LayoutStore {
* Handle reroute deletion
*/
private handleRerouteDelete(rerouteId: RerouteId): void {
const rerouteKey = String(rerouteId)
this.rerouteLayouts.delete(rerouteId)
this.rerouteSpatialIndex.remove(String(rerouteId))
this.rerouteSpatialIndex.remove(rerouteKey)
}
/**
* Handle reroute upsert (update if exists, create if not)
*/
private handleRerouteUpsert(rerouteId: RerouteId): void {
const rerouteData = this.yreroutes.get(String(rerouteId))
const rerouteKey = String(rerouteId)
const rerouteData = this.yreroutes.get(rerouteKey)
if (!rerouteData) return
const position = this.getRerouteField(rerouteData, 'position')
@@ -1509,7 +1500,7 @@ class LayoutStoreImpl implements LayoutStore {
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
const ynode = this.ynodes.get(String(nodeId))
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)

View File

@@ -110,7 +110,7 @@ export function useLayoutSync() {
if (change.nodeIds.length === 0) return
for (const nodeId of change.nodeIds) {
pendingNodeIds.add(nodeId)
pendingNodeIds.add(String(nodeId))
}
scheduleFlush(change.source, canvas)
})

View File

@@ -117,34 +117,6 @@ describe('useTransformState', () => {
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
describe('canvasToScreen', () => {
it('should convert canvas coordinates to screen coordinates', () => {
const { canvasToScreen } = transformState
const canvasPoint = { x: 10, y: 20 }
const screenPoint = canvasToScreen(canvasPoint)
// screen = (canvas + offset) * scale
// x: (10 + 100) * 2 = 220
// y: (20 + 50) * 2 = 140
expect(screenPoint).toEqual({ x: 220, y: 140 })
})
it('should handle zero coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: 0, y: 0 })
expect(screenPoint).toEqual({ x: 200, y: 100 })
})
it('should handle negative coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: -10, y: -20 })
expect(screenPoint).toEqual({ x: 180, y: 60 })
})
})
describe('screenToCanvas', () => {
it('should convert screen coordinates to canvas coordinates', () => {
const { screenToCanvas } = transformState
@@ -157,186 +129,10 @@ describe('useTransformState', () => {
// y: 140 / 2 - 50 = 20
expect(canvasPoint).toEqual({ x: 10, y: 20 })
})
it('should be inverse of canvasToScreen', () => {
const { canvasToScreen, screenToCanvas } = transformState
const originalPoint = { x: 25, y: 35 }
const screenPoint = canvasToScreen(originalPoint)
const backToCanvas = screenToCanvas(screenPoint)
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
})
})
})
describe('getNodeScreenBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
it('should calculate correct screen bounds for a node', () => {
const { getNodeScreenBounds } = transformState
const nodePos: [number, number] = [10, 20]
const nodeSize: [number, number] = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (220, 140)
// Width: 200 * 2 = 400
// Height: 100 * 2 = 200
expect(bounds.x).toBe(220)
expect(bounds.y).toBe(140)
expect(bounds.width).toBe(400)
expect(bounds.height).toBe(200)
})
})
describe('isNodeInViewport', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.scale = 1
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
const viewport = { width: 1000, height: 600 }
it('should return true for nodes inside viewport', () => {
const { isNodeInViewport } = transformState
const nodePos: [number, number] = [100, 100]
const nodeSize: [number, number] = [200, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
})
it('should return false for nodes completely outside viewport', () => {
const { isNodeInViewport } = transformState
// Node far to the right
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
// Node far to the left
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
// Node far below
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
// Node far above
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
})
it('should return true for nodes partially in viewport with margin', () => {
const { isNodeInViewport } = transformState
// Node slightly outside but within margin
const nodePos: [number, number] = [-50, -50]
const nodeSize: [number, number] = [100, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
})
it('should return false for tiny nodes (size culling)', () => {
const { isNodeInViewport } = transformState
// Node is in viewport but too small
const nodePos: [number, number] = [100, 100]
const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
})
it('should adjust margin based on zoom level', () => {
const { isNodeInViewport, syncWithCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Test with very low zoom
mockCanvas.ds.scale = 0.05
syncWithCanvas(mockCanvas as LGraphCanvas)
// Node at edge should still be visible due to increased margin
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
// Test with high zoom
mockCanvas.ds.scale = 4
syncWithCanvas(mockCanvas as LGraphCanvas)
// Margin should be tighter
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
})
})
describe('getViewportBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as LGraphCanvas)
})
it('should calculate viewport bounds in canvas coordinates', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0.2)
// With 20% margin:
// marginX = 1000 * 0.2 = 200
// marginY = 600 * 0.2 = 120
// topLeft in screen: (-200, -120)
// bottomRight in screen: (1200, 720)
// Convert to canvas coordinates (canvas = screen / scale - offset):
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
expect(bounds.x).toBe(-200)
expect(bounds.y).toBe(-110)
expect(bounds.width).toBe(700) // 500 - (-200)
expect(bounds.height).toBe(420) // 310 - (-110)
})
it('should handle zero margin', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0)
// No margin, so viewport bounds are exact
expect(bounds.x).toBe(-100) // 0 / 2 - 100
expect(bounds.y).toBe(-50) // 0 / 2 - 50
expect(bounds.width).toBe(500) // 1000 / 2
expect(bounds.height).toBe(300) // 600 / 2
})
})
describe('edge cases', () => {
it('should handle extreme zoom levels', () => {
const { syncWithCanvas, canvasToScreen } = transformState
const mockCanvas = createMockCanvasContext()
// Very small zoom
mockCanvas.ds.scale = 0.001
syncWithCanvas(mockCanvas as LGraphCanvas)
const point1 = canvasToScreen({ x: 1000, y: 1000 })
expect(point1.x).toBeCloseTo(1)
expect(point1.y).toBeCloseTo(1)
// Very large zoom
mockCanvas.ds.scale = 100
syncWithCanvas(mockCanvas as LGraphCanvas)
const point2 = canvasToScreen({ x: 1, y: 1 })
expect(point2.x).toBe(100)
expect(point2.y).toBe(100)
})
it('should handle zero scale in screenToCanvas', () => {
const { syncWithCanvas, screenToCanvas } = transformState
const mockCanvas = createMockCanvasContext()

View File

@@ -104,24 +104,6 @@ function useTransformStateIndividual() {
camera.z = canvas.ds.scale || 1
}
/**
* Converts canvas coordinates to screen coordinates
*
* Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements.
*
* Formula: screen = (canvas + offset) * scale
*
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
function canvasToScreen(point: Point): Point {
return {
x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z
}
}
/**
* Converts screen coordinates to canvas coordinates
*
@@ -140,111 +122,11 @@ function useTransformStateIndividual() {
}
}
// Get node's screen bounds for culling
function getNodeScreenBounds(
pos: [number, number],
size: [number, number]
): DOMRect {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z
const height = size[1] * camera.z
return new DOMRect(topLeft.x, topLeft.y, width, height)
}
// Helper: Calculate zoom-adjusted margin for viewport culling
function calculateAdjustedMargin(baseMargin: number): number {
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
return baseMargin
}
// Helper: Check if node is too small to be visible at current zoom
function isNodeTooSmall(nodeSize: [number, number]): boolean {
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
return nodeScreenSize < 4
}
// Helper: Calculate expanded viewport bounds with margin
function getExpandedViewportBounds(
viewport: { width: number; height: number },
margin: number
) {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
return {
left: -marginX,
right: viewport.width + marginX,
top: -marginY,
bottom: viewport.height + marginY
}
}
// Helper: Test if node intersects with viewport bounds
function testViewportIntersection(
screenPos: { x: number; y: number },
nodeSize: [number, number],
bounds: { left: number; right: number; top: number; bottom: number }
): boolean {
const nodeRight = screenPos.x + nodeSize[0] * camera.z
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
return !(
nodeRight < bounds.left ||
screenPos.x > bounds.right ||
nodeBottom < bounds.top ||
screenPos.y > bounds.bottom
)
}
// Check if node is within viewport with frustum and size-based culling
function isNodeInViewport(
nodePos: [number, number],
nodeSize: [number, number],
viewport: { width: number; height: number },
margin: number = 0.2
): boolean {
// Early exit for tiny nodes
if (isNodeTooSmall(nodeSize)) return false
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
const adjustedMargin = calculateAdjustedMargin(margin)
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
return testViewportIntersection(screenPos, nodeSize, bounds)
}
// Get viewport bounds in canvas coordinates (for spatial index queries)
function getViewportBounds(
viewport: { width: number; height: number },
margin: number = 0.2
) {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
const bottomRight = screenToCanvas({
x: viewport.width + marginX,
y: viewport.height + marginY
})
return {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y
}
}
return {
camera: readonly(camera),
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
getNodeScreenBounds,
isNodeInViewport,
getViewportBounds
screenToCanvas
}
}

View File

@@ -6,6 +6,8 @@
*/
import type { ComputedRef, Ref } from 'vue'
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
// Enum for layout source types
export enum LayoutSource {
Canvas = 'canvas',
@@ -37,7 +39,7 @@ export interface NodeBoundsUpdate {
bounds: Bounds
}
export type NodeId = string
export type NodeId = WorkflowNodeId
export type LinkId = number
export type RerouteId = number

View File

@@ -15,21 +15,21 @@ import { QuadTree } from './QuadTree'
/**
* Cache entry for spatial queries
*/
interface CacheEntry {
result: NodeId[]
interface CacheEntry<TId> {
result: TId[]
timestamp: number
}
/**
* Spatial index manager using QuadTree
*/
export class SpatialIndexManager {
private quadTree: QuadTree<NodeId>
private queryCache: Map<string, CacheEntry>
export class SpatialIndexManager<TId extends string | number = NodeId> {
private quadTree: QuadTree<TId>
private queryCache: Map<string, CacheEntry<TId>>
private cacheSize = 0
constructor(bounds?: Bounds) {
this.quadTree = new QuadTree<NodeId>(
this.quadTree = new QuadTree<TId>(
bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
{
maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
@@ -42,16 +42,16 @@ export class SpatialIndexManager {
/**
* Insert a node into the spatial index
*/
insert(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.insert(nodeId, bounds, nodeId)
insert(nodeId: TId, bounds: Bounds): void {
this.quadTree.insert(String(nodeId), bounds, nodeId)
this.invalidateCache()
}
/**
* Update a node's bounds in the spatial index
*/
update(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.update(nodeId, bounds)
update(nodeId: TId, bounds: Bounds): void {
this.quadTree.update(String(nodeId), bounds)
this.invalidateCache()
}
@@ -59,9 +59,9 @@ export class SpatialIndexManager {
* Batch update multiple nodes' bounds in the spatial index
* More efficient than calling update() multiple times as it only invalidates cache once
*/
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
batchUpdate(updates: Array<{ nodeId: TId; bounds: Bounds }>): void {
for (const { nodeId, bounds } of updates) {
this.quadTree.update(nodeId, bounds)
this.quadTree.update(String(nodeId), bounds)
}
this.invalidateCache()
}
@@ -69,15 +69,15 @@ export class SpatialIndexManager {
/**
* Remove a node from the spatial index
*/
remove(nodeId: NodeId): void {
this.quadTree.remove(nodeId)
remove(nodeId: TId): void {
this.quadTree.remove(String(nodeId))
this.invalidateCache()
}
/**
* Query nodes within the given bounds
*/
query(bounds: Bounds): NodeId[] {
query(bounds: Bounds): TId[] {
const cacheKey = this.getCacheKey(bounds)
const cached = this.queryCache.get(cacheKey)
@@ -137,7 +137,7 @@ export class SpatialIndexManager {
/**
* Add result to cache with LRU eviction
*/
private addToCache(key: string, result: NodeId[]): void {
private addToCache(key: string, result: TId[]): void {
// Evict oldest entries if cache is full
if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
const oldestKey = this.findOldestCacheEntry()

View File

@@ -1,5 +1,6 @@
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useExecutionStore } from '@/stores/executionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { MinimapNodeData } from '../types'
import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
@@ -25,7 +26,8 @@ export class LayoutStoreDataSource extends AbstractMinimapDataSource {
// Find corresponding LiteGraph node for additional properties
const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
const executionState = nodeProgressStates[nodeId]?.state ?? null
const executionState =
nodeProgressStates[createNodeLocatorId(null, nodeId)]?.state ?? null
nodes.push({
id: nodeId,

View File

@@ -15,6 +15,10 @@ vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
vi.mock('@/services/hdrViewerService', () => ({
openHdrViewer: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -36,6 +40,10 @@ const i18n = createI18n({
loading: 'Loading',
viewGrid: 'Grid view',
galleryThumbnail: 'Gallery thumbnail'
},
hdrViewer: {
hdrImage: 'HDR image',
openInHdrViewer: 'Open in HDR Viewer'
}
}
}
@@ -86,6 +94,15 @@ describe('ImagePreview', () => {
expect(container.querySelector('.image-preview')).not.toBeInTheDocument()
})
it('offers the HDR viewer instead of an <img> for exr outputs', () => {
renderImagePreview({
imageUrls: ['/api/view?filename=out.exr&type=output']
})
expect(screen.getByTestId('hdr-open-button')).toBeInTheDocument()
expect(screen.queryByTestId('main-image')).not.toBeInTheDocument()
})
it('displays calculating dimensions text in gallery mode', async () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]

View File

@@ -23,15 +23,23 @@
total: imageUrls.length
})
"
@click="openImageInGallery(index)"
@click="handleGridClick(index)"
>
<img
v-if="!isHdrImageUrl(imageUrls[index])"
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
@load="updateAspectRatio($event, index)"
/>
<div
v-else
class="flex size-full flex-col items-center justify-center gap-1 text-base-foreground"
>
<i class="icon-[lucide--sun] size-6" />
<span class="text-xs">{{ $t('hdrViewer.hdrImage') }}</span>
</div>
</Button>
</div>
@@ -61,12 +69,30 @@
</p>
</div>
<!-- Loading State -->
<div v-if="showLoader && !imageError" class="size-full">
<div
v-if="showLoader && !imageError && !currentImageIsHdr"
class="size-full"
>
<Skeleton class="size-full rounded-sm" />
</div>
<button
v-if="!imageError && currentImageIsHdr"
type="button"
data-testid="hdr-open-button"
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-3 border-0 bg-transparent text-base-foreground"
@click="openHdrViewer(currentImageUrl)"
>
<i class="icon-[lucide--sun] size-12" />
<span class="text-sm">{{ $t('hdrViewer.hdrImage') }}</span>
<span
class="rounded-md bg-base-foreground px-3 py-1.5 text-sm text-base-background"
>
{{ $t('hdrViewer.openInHdrViewer') }}
</span>
</button>
<!-- Main Image -->
<img
v-if="!imageError"
v-if="!imageError && !currentImageIsHdr"
data-testid="main-image"
:src="currentImageUrl"
:alt="imageAltText"
@@ -82,7 +108,7 @@
>
<!-- Mask/Edit Button -->
<button
v-if="!hasMultipleImages && !imageError"
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
:class="actionButtonClass"
:title="$t('g.editOrMaskImage')"
:aria-label="$t('g.editOrMaskImage')"
@@ -117,7 +143,7 @@
<!-- Image Dimensions (gallery mode only) -->
<div
v-if="viewMode === 'gallery'"
v-if="viewMode === 'gallery' && !currentImageIsHdr"
class="pt-2 text-center text-xs text-base-foreground"
>
<span
@@ -178,7 +204,9 @@ import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { openHdrViewer } from '@/services/hdrViewerService'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isHdrImageUrl } from '@/utils/hdrFormatUtil'
import { getGridThumbnailUrl } from '@/utils/imageUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -228,6 +256,7 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
)
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
const currentImageIsHdr = computed(() => isHdrImageUrl(currentImageUrl.value))
const gridImageUrls = computed(() => imageUrls.map(getGridThumbnailUrl))
const hasMultipleImages = computed(() => imageUrls.length > 1)
const imageAltText = computed(() =>
@@ -333,6 +362,15 @@ async function openImageInGallery(index: number) {
galleryPanelEl.value?.focus()
}
function handleGridClick(index: number) {
const url = imageUrls[index]
if (isHdrImageUrl(url)) {
openHdrViewer(url)
return
}
void openImageInGallery(index)
}
function getNavigationDotClass(index: number) {
return cn(
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',

View File

@@ -48,7 +48,7 @@
nodeData.mode === LGraphEventMode.ALWAYS &&
!nodeData.hasErrors
"
:id="nodeData.id"
:id="nodeId"
/>
<div
v-if="isSelected || executing"
@@ -340,11 +340,13 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(String(nodeData.id), 'node')
const nodeId = computed(() => String(nodeData.id))
useVueElementTracking(nodeId.value, 'node')
const { selectedNodeIds, isGhostPlacing } = storeToRefs(useCanvasStore())
const isSelected = computed(() => {
return selectedNodeIds.value.has(nodeData.id)
return selectedNodeIds.value.has(nodeId.value)
})
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
@@ -353,7 +355,7 @@ const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
() => executionErrorStore.lastExecutionErrorNodeId === nodeId.value
)
const hasAnyError = computed((): boolean => {

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