Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions
5ea3a719fc Update locales 2026-04-13 00:14:50 +00:00
christian-byrne
e618915fab [release] Increment version to 1.44.4 2026-04-13 00:09:38 +00:00
Comfy Org PR Bot
63435bdb34 1.44.3 (#11170)
Patch version increment to 1.44.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11170-1-44-3-3406d73d365081799aa4e189009d123b)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-04-12 23:11:20 +00:00
Kelly Yang
20255da61f feat(load3d): add optional HDRI environment lighting to 3D preview nodes (#10818)
## Summary

Adds `HDRIManager` to load `.hdr/.exr` files as equirectangular
environment maps via **three.js** `RGBELoader/EXRLoader`
- Uploads HDRI files to the server via `/upload/image` API so they
persist across page reloads
- Restores HDRI state (enabled, **intensity**, **background**) from node
properties on reload
- Auto-enables "**Show as Background**" on successful upload for
immediate visual feedback
- Hides standard directional lights when HDRI is active; restores them
when disabled
- Hides the Light Intensity control while HDRI is active (lights have no
effect when HDRI overrides scene lighting)
- Limits HDRI availability to PBR-capable formats (.gltf, .glb, .fbx,
.obj); automatically disables when switching to an incompatible model
- Adds intensity slider and "**Show as Background**" toggle to the HDRI
panel

## How to Use HDRI Environment Lighting
1. Load a 3D model using a Load3D or Load3DViewer node (supported
formats: .gltf, .glb, .fbx, .obj)
2. Open the control panel → go to the Light tab
3. Click the globe icon to open the **HDRI panel**
4. Click Upload HDRI and select a` .hdr` or `.exr` file
5. The environment lighting applies automatically — the scene background
also updates to preview the panorama
6. Use the intensity slider to adjust the strength of the environment
lighting
7. Toggle Show as Background to show or hide the HDRI panorama behind
the model

## Screenshots



https://github.com/user-attachments/assets/1ec56ef0-853e-452f-ae2b-2474c9d0d781



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10818-feat-load3d-add-optional-HDRI-environment-lighting-to-3D-preview-nodes-3366d73d365081ea8c7ad9226b8b1e2f)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new HDRI loading/rendering path and persists new
`LightConfig.hdri` state, touching Three.js rendering, file uploads, and
node property restoration. Risk is moderate due to new async flows and
potential compatibility/performance issues with model switching and
renderer settings.
> 
> **Overview**
> Adds optional **HDRI environment lighting** to Load3D previews,
including a new `HDRIManager` that loads `.hdr`/`.exr` files into
Three.js environment/background and exposes controls for enable/disable,
background display, and intensity.
> 
> Extends `LightConfig` with an `hdri` block that is persisted on nodes
and restored on reload; `useLoad3d` now uploads HDRI files, loads them
into `Load3d`, maps scene light intensity to HDRI intensity, and
auto-disables HDRI when the current model format doesn’t support it.
> 
> Updates the UI to include embedded HDRI controls under the Light panel
(with dismissable overlays and icon updates), adjusts light intensity
behavior when HDRI is active, and adds tests/strings/utilities
(`getFilenameExtension`, `mapSceneLightIntensityToHdri`, new constants)
to support the feature.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b12c9722dc. 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: Terry Jia <terryjia88@gmail.com>
2026-04-12 05:55:48 -04:00
Christian Byrne
c2dba8f4ee chore(#11080): consolidate duplicate rgbToHSL — use shared colorUtil (#11134)
## Summary

Consolidate duplicate `rgbToHSL` implementation — mask editor now uses
the shared `colorUtil.ts` version instead of its own copy.

## Changes

- Export `rgbToHsl` from `src/utils/colorUtil.ts` (was private)
- Replace 30-line local `rgbToHSL` in `useCanvasTools.ts` with a 2-line
wrapper that imports from `colorUtil.ts` and scales the return values
from 0-1 to degree/percentage

## Testing

### Automated

- All 176 existing tests pass (`colorUtil.test.ts` + `maskeditor/`
suite)
- No new tests needed — behavior is identical

### E2E Verification Steps

1. Open any image in the mask editor
2. Select the magic wand / color picker tool
3. Use HSL-based color matching — results should be identical to before

## Review Focus

The canonical `rgbToHsl` returns normalized 0-1 values while the mask
editor needs degree/percentage scale (h: 0-360, s: 0-100, l: 0-100). The
local wrapper handles this conversion.

Fixes #11080

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11134-chore-11080-consolidate-duplicate-rgbToHSL-use-shared-colorUtil-33e6d73d36508120bbd8f444f5cc94b6)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-12 01:40:55 +00:00
Alexander Brown
6f579c5992 fix: enable playwright/no-force-option lint rule (#11164)
## Summary

Enable the previously disabled `playwright/no-force-option` lint rule at
error level and resolve all 29 violations across 10 files.

## Changes

### Lint rule
- `.oxlintrc.json`: `playwright/no-force-option` changed from `off` to
`error`

### Shared utility
- `CanvasHelper.ts`: Add `mouseClickAt()` and `mouseDblclickAt()`
methods that convert canvas-element-relative positions to absolute page
coordinates and use `page.mouse` APIs, avoiding Playwright's locator
actionability checks that fail when Vue DOM overlays sit above the
`<canvas>` element

### Force removal (20 violations)
- `selectionToolboxActions.spec.ts`: Remove `force: true` from 8 toolbox
button clicks (the `pointer-events: none` splitter overlay does not
intercept `elementFromPoint()`)
- `selectionToolboxSubmenus.spec.ts`: Remove `force: true` from 2
popover menu item clicks
- `BuilderSelectHelper.ts`: Remove `force: true` from 2 widget/node
clicks (builder mode does not disable pointer events)
- `linkInteraction.spec.ts`: Remove `force: true` from 3 slot `dragTo()`
calls (`::after` pseudo-elements do not intercept `elementFromPoint()`)
- `SidebarTab.ts`: Remove `force: true` from toast dismissal (`.catch()`
already handles failures)
- `nodeHelp.spec.ts`: Remove `force: true` from info button click
(preceding `toBeVisible()` assertion is sufficient)

### Rewrites (3 violations)
- `integerWidget.spec.ts`: Replace force-clicking disabled buttons with
`toBeDisabled()` assertions
- `Topbar.ts`: Replace force-click with `waitFor({ state: 'visible' })`
after hover

### Canvas coordinate clicks (9 violations)
- `litegraphUtils.ts`: Convert `NodeReference.click()` and
`navigateIntoSubgraph()` to use
`canvasOps.mouseClickAt()`/`mouseDblclickAt()`
- `subgraphPromotion.spec.ts`: Convert 3 right-click canvas calls to
`canvasOps.mouseClickAt()`
- `selectionToolboxSubmenus.spec.ts`: Convert 1 canvas dismiss-click to
`canvasOps.mouseClickAt()`

## Rationale

The original `force: true` usages were added defensively based on
incorrect assumptions about the `z-999 pointer-events: none` splitter
overlay intercepting Playwright's actionability checks. In reality,
`elementFromPoint()` skips elements with `pointer-events: none`, so the
overlay is transparent to Playwright's hit-test.

For canvas coordinate clicks, `force: true` on a locator does not tunnel
through DOM overlays — it only skips Playwright's preflight checks.
`page.mouse.click()` is the correct API for coordinate-based canvas
interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11164-fix-enable-playwright-no-force-option-lint-rule-33f6d73d365081e78601c6114121d272)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-11 19:59:34 +00:00
Dante
e729e5edb8 fix: place cloned node above original in Vue renderer (#10361)
## Summary

Cloned/pasted nodes in Node 2.0 (Vue renderer) mode now appear above the
original node instead of behind it.

## Root Cause

The legacy LiteGraph canvas renderer uses array ordering for z-ordering:
nodes are stored in `graph._nodes` and drawn sequentially, so newly
added nodes (appended to the end) are automatically drawn on top. There
is no explicit z-index.

The Vue renderer (Node 2.0) uses explicit CSS `z-index` for node
ordering. New nodes default to `zIndex: 0` in `layoutMutations.ts`. When
a node has been interacted with, `bringNodeToFront` raises its z-index.
A cloned node at z-index 0 therefore appears behind any previously
interacted node.

The alt-click clone path in `LGraphNode.vue` already handles this
correctly by calling `bringNodeToFront()` after cloning. However, the
menu clone and keyboard paste paths go through `_deserializeItems` in
`LGraphCanvas.ts`, which does not set z-index for new nodes.

| Clone method | Legacy renderer | Vue renderer (before fix) | Vue
renderer (after fix) |
|---|---|---|---|
| Alt-click drag | On top (array order) | On top (`bringNodeToFront`
called) | On top |
| Right-click menu Clone | On top (array order) | Behind original
(z-index 0) | On top |
| Ctrl+C / Ctrl+V | On top (array order) | Behind original (z-index 0) |
On top |

## Steps to Reproduce

1. Enable Node 2.0 mode (Vue renderer) in settings
2. Add any node to the canvas
3. Click or drag the node (raises its z-index via `bringNodeToFront`)
4. Right-click the node and select "Clone"
5. **Expected**: Cloned node appears above the original, immediately
draggable
6. **Actual**: Cloned node appears behind the original; user must move
the original to access the clone

## Changes

After `batchUpdateNodeBounds` in `_deserializeItems`, calls
`bringNodeToFront` for each newly created node so they receive a z-index
above all existing nodes.

## Side Effect Analysis

Checked all call sites of `_deserializeItems`:

1. **Initial graph load / workflow open**: `loadGraphData` in `app.ts`
does NOT call `_deserializeItems`. Workflow loading goes through
`LGraph.configure()` which directly adds nodes and links. The layout
store is initialized separately via `initializeFromLiteGraph`. No side
effect.

2. **Paste from clipboard (Ctrl+V)**: Both `usePaste.ts` (line 52) and
`pasteFromClipboard` (line 4080) call `_deserializeItems`. Pasted nodes
appearing on top is the correct and desired behavior. No issue.

3. **Undo/Redo**: `ChangeTracker.updateState()` calls
`app.loadGraphData()`, which does a full graph reconfigure -- it does
NOT go through `_deserializeItems`. No side effect.

4. **Subgraph blueprint addition**: `litegraphService.ts` (line 906)
calls `_deserializeItems` when adding subgraph blueprints from the node
library. These are freshly placed nodes that should appear on top.
Desired behavior.

5. **Alt-click clone in LGraphNode.vue**: This path calls
`LGraphCanvas.cloneNodes()` -> `_deserializeItems()`, then separately
calls `bringNodeToFront()` again on line 433 of `LGraphNode.vue`. The
second call is now redundant (the node is already at max z-index), but
harmless -- `bringNodeToFront` finds the current max, adds 1, and sets.
The z-index will increment from N to N+1 on the second call. This is a
minor redundancy, not a bug.

6. **Performance**: `bringNodeToFront` iterates all nodes in the layout
store once per call (O(m)) to find max z-index. For n new nodes, the
total cost is O(n*m). In practice, clone/paste operations involve a
small number of nodes (typically 1-10), so this is negligible. For
extremely large pastes (100+ nodes), each call also increments the max
by 1, so z-indices will be sequential (which is actually a reasonable
stacking order).

7. **layoutStore availability**: `layoutStore` is a module-level
singleton (`new LayoutStoreImpl()`) -- not a Pinia store -- so it is
always available. The `useLayoutMutations()` composable is a plain
function returning an object of closures over `layoutStore`. It does not
require Vue component context. No risk of runtime errors.

8. **Legacy renderer (non-Vue mode)**: When Node 2.0 mode is disabled,
the layout store still exists but is not used for rendering. Calling
`bringNodeToFront` will update z-index values in the Yjs document that
are never read. This is harmless.

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `6894b99` `test:` | RED | Test asserts cloned node z-index > original.
Fails with `expected 0 to be greater than 5`. |
| `3567469` `fix:` | GREEN | Calls `bringNodeToFront` for each new node
in `_deserializeItems`. Test passes. |

Fixes #10307

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-11 12:12:37 +00:00
Alexander Brown
3043b181d7 refactor: extract composables from VTU holdout components, complete VTL migration (#10966)
## Summary

Extract internal logic from the 2 remaining VTU holdout components into
composables, enabling full VTL migration.

## Changes

- **What**: Extract `useProcessedWidgets` from `NodeWidgets.vue`
(486→135 LOC) and `useWidgetSelectItems`/`useWidgetSelectActions` from
`WidgetSelectDropdown.vue` (563→170 LOC). Rewrite both component test
files as composable unit tests + slim behavioral VTL tests. Remove
`@vue/test-utils` devDependency.
- **Dependencies**: Removes `@vue/test-utils`

## Review Focus

- Composable extraction is mechanical — no logic changes, just moving
code into testable units
- `useProcessedWidgets` handles widget deduplication, promotion border
styling, error detection, and identity resolution (~290 LOC)
- `useWidgetSelectItems` handles the full computed chain from widget
values → dropdown items including cloud asset mode and multi-output job
resolution (~350 LOC)
- `useWidgetSelectActions` handles selection resolution and file upload
(~120 LOC)
- 40 new composable-level unit tests replace 13 `wrapper.vm.*` accesses
across the 2 holdout files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10966-refactor-extract-composables-from-VTU-holdout-components-complete-VTL-migration-33c6d73d36508148a3a4ccf346722d6d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 19:04:16 -07:00
65 changed files with 4216 additions and 1923 deletions

View File

@@ -150,7 +150,7 @@
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "off",
"playwright/no-force-option": "error",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",

View File

@@ -351,7 +351,7 @@ export class AssetsSidebarTab extends SidebarTab {
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
await btn.click().catch(() => {})
}
// Wait for all toast elements to fully animate out and detach from DOM
await expect(this.page.locator('.p-toast-message'))

View File

@@ -71,7 +71,7 @@ export class Topbar {
async closeWorkflowTab(tabName: string) {
const tab = this.getWorkflowTab(tabName)
await tab.hover()
await tab.locator('.close-button').click({ force: true })
await tab.locator('.close-button').click()
}
getSaveDialog(): Locator {

View File

@@ -151,6 +151,7 @@ export class BuilderSelectHelper {
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true })
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame()
}
@@ -199,6 +200,7 @@ export class BuilderSelectHelper {
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
String(nodeRef.id)
)
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await nodeLocator.click({ force: true })
await this.comfyPage.nextFrame()
}

View File

@@ -74,6 +74,51 @@ export class CanvasHelper {
await this.nextFrame()
}
/**
* Convert a canvas-element-relative position to absolute page coordinates.
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
* cause Playwright's actionability check to fail on the canvas locator.
*/
private async toAbsolute(position: Position): Promise<Position> {
const box = await this.canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
return { x: box.x + position.x, y: box.y + position.y }
}
/**
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
* Bypasses Playwright's actionability checks on the canvas locator, which
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
*/
async mouseClickAt(
position: Position,
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
}
): Promise<void> {
const abs = await this.toAbsolute(position)
const modifiers = options?.modifiers ?? []
for (const mod of modifiers) await this.page.keyboard.down(mod)
try {
await this.page.mouse.click(abs.x, abs.y, {
button: options?.button
})
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
}
/**
* Double-click at canvas-element-relative coordinates using `page.mouse`.
*/
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
@@ -356,7 +355,11 @@ export class NodeReference {
}
async click(
position: 'title' | 'collapse',
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
options?: {
button?: 'left' | 'right' | 'middle'
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
moveMouseToEmptyArea?: boolean
}
) {
let clickPos: Position
switch (position) {
@@ -377,12 +380,7 @@ export class NodeReference {
delete options.moveMouseToEmptyArea
}
await this.comfyPage.canvas.click({
...options,
position: clickPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
if (moveMouseToEmptyArea) {
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
}
@@ -499,31 +497,18 @@ export class NodeReference {
await expect(async () => {
// Try just clicking the enter button first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
await this.comfyPage.canvas.click({
position: subgraphButtonPos,
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
if (await checkIsInSubgraph()) return
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
// Double-click to enter subgraph
await this.comfyPage.canvas.dblclick({ position, force: true })
await this.comfyPage.nextFrame()
await this.comfyPage.canvasOps.mouseDblclickAt(position)
if (await checkIsInSubgraph()) return
}

View File

@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
await expect(helpButton).toBeVisible()
await helpButton.click({ force: true })
await helpButton.click()
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')

View File

@@ -49,7 +49,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -65,7 +65,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await infoButton.click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -98,7 +98,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await deleteButton.click()
await comfyPage.nextFrame()
await expect
@@ -120,7 +120,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const bypassButton = comfyPage.page.getByTestId('bypass-button')
await expect(bypassButton).toBeVisible()
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
@@ -128,7 +128,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
BYPASS_CLASS
)
await bypassButton.click({ force: true })
await bypassButton.click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
@@ -147,7 +147,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
// KSampler should be gone, replaced by a subgraph node
@@ -175,7 +175,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await convertButton.click()
await comfyPage.nextFrame()
await expect
@@ -200,13 +200,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await comfyPage.page
await expect(
comfyPage.selectionToolbox.getByRole('button', {
name: /Frame Nodes/i
})
).toBeVisible()
await comfyPage.selectionToolbox
.getByRole('button', { name: /Frame Nodes/i })
.click({ force: true })
.click()
await comfyPage.nextFrame()
await expect

View File

@@ -62,7 +62,7 @@ test.describe(
return
}
await moreOptionsBtn.click({ force: true })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
@@ -126,9 +126,7 @@ test.describe(
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
await openMoreOptions(comfyPage)
await comfyPage.page
.getByText('Rename', { exact: true })
.click({ force: true })
await comfyPage.page.getByText('Rename', { exact: true }).click()
const input = comfyPage.page.locator(
'.group-title-editor.node-title-editor .editable-text input'
)
@@ -153,11 +151,7 @@ test.describe(
await comfyPage.nextFrame()
}
await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt({ x: 0, y: 50 })
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeHidden()

View File

@@ -199,12 +199,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
@@ -235,12 +230,7 @@ test.describe(
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
@@ -266,12 +256,7 @@ test.describe(
const stepsWidget2 = await ksampler2.getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
position: widgetPos2,
button: 'right',
force: true
})
await comfyPage.nextFrame()
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
const unpromoteEntry = comfyPage.page
.locator('.litemenu-entry')

View File

@@ -94,6 +94,7 @@ async function connectSlots(
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await fromLoc.dragTo(toLoc, { force: true })
await nextFrame()
}
@@ -192,6 +193,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()
@@ -218,6 +220,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
await expectVisibleAll(outputSlot, inputSlot)
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
await outputSlot.dragTo(inputSlot, { force: true })
await comfyPage.nextFrame()

View File

@@ -22,10 +22,8 @@ test.describe('Vue Integer Widget', () => {
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
await expect(controls.incrementButton).toBeDisabled()
await expect(controls.decrementButton).toBeDisabled()
await expect(controls.input).toHaveValue(initialValue.toString())
await expect(seedWidget).toBeVisible()

View File

@@ -23,7 +23,7 @@ See `docs/testing/*.md` for detailed patterns.
## Component Testing
- Use Vue Test Utils for component tests
- Use `@testing-library/vue` with `@testing-library/user-event` for component tests (an ESLint rule bans `@vue/test-utils` in new tests)
- Follow advice about making components easy to test
- Wait for reactivity with `await nextTick()` after state changes

View File

@@ -31,7 +31,7 @@ Our tests use the following frameworks and libraries:
- [Vitest](https://vitest.dev/) - Test runner and assertion library
- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing
- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (legacy; new tests must use @testing-library/vue)
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started

View File

@@ -1,5 +1,7 @@
# Component Testing Guide
> **Note**: New component tests must use `@testing-library/vue` with `@testing-library/user-event`. The examples below that use `@vue/test-utils` (`mount`, `wrapper`) are from legacy tests. An ESLint rule enforces this — importing from `@vue/test-utils` in `*.test.ts` files produces a lint error.
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
## Table of Contents

View File

@@ -432,6 +432,23 @@ export default defineConfig([
]
}
},
{
files: ['**/*.test.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@vue/test-utils',
message:
'Use @testing-library/vue with @testing-library/user-event instead.'
}
]
}
]
}
},
// Browser tests must use comfyPageFixture, not raw @playwright/test test
{
files: ['browser_tests/tests/**/*.spec.ts'],

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.2",
"version": "1.44.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -150,7 +150,6 @@
"@vitejs/plugin-vue": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"cross-env": "catalog:",
"eslint": "catalog:",

6
pnpm-lock.yaml generated
View File

@@ -171,9 +171,6 @@ catalogs:
'@vitest/ui':
specifier: ^4.0.16
version: 4.0.16
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
'@vueuse/core':
specifier: ^14.2.0
version: 14.2.0
@@ -693,9 +690,6 @@ importers:
'@vitest/ui':
specifier: 'catalog:'
version: 4.0.16(vitest@4.0.16)
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
'@webgpu/types':
specifier: 'catalog:'
version: 0.1.66

View File

@@ -58,7 +58,6 @@ catalog:
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66

View File

@@ -27,6 +27,7 @@
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
/>
<AnimationControls
v-if="animations && animations.length > 0"
@@ -139,6 +140,7 @@ const {
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
cleanup

View File

@@ -6,19 +6,21 @@
@pointerup.stop
@wheel.stop
>
<div class="show-menu relative">
<div class="relative">
<Button
ref="menuTriggerRef"
variant="textonly"
size="icon"
:aria-label="$t('menu.showMenu')"
class="rounded-full"
@click="toggleMenu"
>
<i class="pi pi-bars text-lg text-base-foreground" />
<i class="icon-[lucide--menu] text-lg text-base-foreground" />
</Button>
<div
v-show="isMenuOpen"
ref="menuPanelRef"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
>
<div class="flex flex-col">
@@ -42,7 +44,6 @@
</div>
</div>
</div>
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<SceneControls
v-if="showSceneControls"
@@ -51,6 +52,9 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@update-background-image="handleBackgroundImageUpdate"
/>
@@ -70,11 +74,19 @@
v-model:fov="cameraConfig!.fov"
/>
<LightControls
v-if="showLightControls"
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
/>
<div v-if="showLightControls" class="flex flex-col">
<LightControls
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
v-model:hdri-config="lightConfig!.hdri"
/>
<HDRIControls
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
/>
</div>
<ExportControls
v-if="showExportControls"
@@ -85,10 +97,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
@@ -117,6 +131,17 @@ const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const isMenuOpen = ref(false)
const menuPanelRef = ref<HTMLElement | null>(null)
const menuTriggerRef = ref<InstanceType<typeof Button> | null>(null)
useDismissableOverlay({
isOpen: isMenuOpen,
getOverlayEl: () => menuPanelRef.value,
getTriggerEl: () => menuTriggerRef.value?.$el ?? null,
onDismiss: () => {
isMenuOpen.value = false
}
})
const activeCategory = ref<string>('scene')
const categoryLabels: Record<string, string> = {
scene: 'load3d.scene',
@@ -160,21 +185,26 @@ const selectCategory = (category: string) => {
isMenuOpen.value = false
}
const categoryIcons = {
scene: 'icon-[lucide--image]',
model: 'icon-[lucide--box]',
camera: 'icon-[lucide--camera]',
light: 'icon-[lucide--sun]',
export: 'icon-[lucide--download]'
} as const
const getCategoryIcon = (category: string) => {
const icons = {
scene: 'pi pi-image',
model: 'pi pi-box',
camera: 'pi pi-camera',
light: 'pi pi-sun',
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-base-foreground text-lg`
const icon =
category in categoryIcons
? categoryIcons[category as keyof typeof categoryIcons]
: 'icon-[lucide--circle]'
return cn(icon, 'text-lg text-base-foreground')
}
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'updateHdriFile', file: File | null): void
}>()
const handleBackgroundImageUpdate = (file: File | null) => {
@@ -185,19 +215,7 @@ const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-menu')) {
isMenuOpen.value = false
}
const handleHDRIFileUpdate = (file: File | null) => {
emit('updateHdriFile', file)
}
onMounted(() => {
document.addEventListener('click', closeSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div v-if="!hasBackgroundImage || hdriConfig?.hdriPath" class="flex flex-col">
<Button
v-tooltip.right="{
value: hdriConfig?.hdriPath
? $t('load3d.hdri.changeFile')
: $t('load3d.hdri.uploadFile'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="
hdriConfig?.hdriPath
? $t('load3d.hdri.changeFile')
: $t('load3d.hdri.uploadFile')
"
@click="triggerFileInput"
>
<i class="icon-[lucide--upload] text-lg text-base-foreground" />
</Button>
<template v-if="hdriConfig?.hdriPath">
<Button
v-tooltip.right="{
value: $t('load3d.hdri.label'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn('rounded-full', hdriConfig?.enabled && 'ring-2 ring-white/50')
"
:aria-label="$t('load3d.hdri.label')"
@click="toggleEnabled"
>
<i class="icon-[lucide--globe] text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: $t('load3d.hdri.showAsBackground'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="
cn(
'rounded-full',
hdriConfig?.showAsBackground && 'ring-2 ring-white/50'
)
"
:aria-label="$t('load3d.hdri.showAsBackground')"
@click="toggleShowAsBackground"
>
<i class="icon-[lucide--image] text-lg text-base-foreground" />
</Button>
<Button
v-tooltip.right="{
value: $t('load3d.hdri.removeFile'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.hdri.removeFile')"
@click="onRemoveHDRI"
>
<i class="icon-[lucide--x] text-lg text-base-foreground" />
</Button>
</template>
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
@change="onFileChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import {
SUPPORTED_HDRI_EXTENSIONS,
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { hasBackgroundImage = false } = defineProps<{
hasBackgroundImage?: boolean
}>()
const hdriConfig = defineModel<HDRIConfig>('hdriConfig')
const emit = defineEmits<{
(e: 'updateHdriFile', file: File | null): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
input.value = ''
if (file) {
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
return
}
}
emit('updateHdriFile', file)
}
function toggleEnabled() {
if (!hdriConfig.value) return
hdriConfig.value = {
...hdriConfig.value,
enabled: !hdriConfig.value.enabled
}
}
function toggleShowAsBackground() {
if (!hdriConfig.value) return
hdriConfig.value = {
...hdriConfig.value,
showAsBackground: !hdriConfig.value.showAsBackground
}
}
function onRemoveHDRI() {
emit('updateHdriFile', null)
}
</script>

View File

@@ -1,7 +1,24 @@
<template>
<div class="flex flex-col">
<div v-if="showLightIntensityButton" class="show-light-intensity relative">
<div
v-if="embedded && showIntensityControl"
class="flex w-[200px] flex-col gap-2 rounded-lg bg-black/50 p-3 shadow-lg"
>
<span class="text-sm font-medium text-base-foreground">{{
$t('load3d.lightIntensity')
}}</span>
<Slider
:model-value="sliderValue"
class="w-full"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
@update:model-value="onSliderUpdate"
/>
</div>
<div v-else-if="showIntensityControl" class="relative">
<Button
ref="triggerRef"
v-tooltip.right="{
value: $t('load3d.lightIntensity'),
showDelay: 300
@@ -12,19 +29,20 @@
:aria-label="$t('load3d.lightIntensity')"
@click="toggleLightIntensity"
>
<i class="pi pi-sun text-lg text-base-foreground" />
<i class="icon-[lucide--sun] text-lg text-base-foreground" />
</Button>
<div
v-show="showLightIntensity"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
ref="panelRef"
class="absolute top-0 left-12 w-[200px] rounded-lg bg-black/50 p-3 shadow-lg"
>
<Slider
v-model="lightIntensity"
:model-value="sliderValue"
class="w-full"
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
:min="sliderMin"
:max="sliderMax"
:step="sliderStep"
@update:model-value="onSliderUpdate"
/>
</div>
</div>
@@ -32,20 +50,30 @@
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
import Slider from '@/components/ui/slider/Slider.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type {
HDRIConfig,
MaterialMode
} from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
const materialMode = defineModel<MaterialMode>('materialMode')
const hdriConfig = defineModel<HDRIConfig | undefined>('hdriConfig')
const showLightIntensityButton = computed(
() => materialMode.value === 'original'
const { embedded = false } = defineProps<{
embedded?: boolean
}>()
const usesHdriIntensity = computed(
() => !!hdriConfig.value?.hdriPath?.length && !!hdriConfig.value?.enabled
)
const showLightIntensity = ref(false)
const showIntensityControl = computed(() => materialMode.value === 'original')
const lightIntensityMaximum = useSettingStore().get(
'Comfy.Load3D.LightIntensityMaximum'
@@ -57,23 +85,49 @@ const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
const sliderMin = computed(() =>
usesHdriIntensity.value ? 0 : lightIntensityMinimum
)
const sliderMax = computed(() =>
usesHdriIntensity.value ? 5 : lightIntensityMaximum
)
const sliderStep = computed(() =>
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
)
const sliderValue = computed(() => {
if (usesHdriIntensity.value) {
return [hdriConfig.value?.intensity ?? 1]
}
return [lightIntensity.value ?? lightIntensityMinimum]
})
const showLightIntensity = ref(false)
const panelRef = ref<HTMLElement | null>(null)
const triggerRef = ref<InstanceType<typeof Button> | null>(null)
useDismissableOverlay({
isOpen: showLightIntensity,
getOverlayEl: () => panelRef.value,
getTriggerEl: () => triggerRef.value?.$el ?? null,
onDismiss: () => {
showLightIntensity.value = false
}
})
function toggleLightIntensity() {
showLightIntensity.value = !showLightIntensity.value
}
function closeLightSlider(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.show-light-intensity')) {
showLightIntensity.value = false
function onSliderUpdate(value: number[] | undefined) {
if (!value?.length) return
const next = value[0]
if (usesHdriIntensity.value) {
const h = hdriConfig.value
if (!h) return
hdriConfig.value = { ...h, intensity: next }
} else {
lightIntensity.value = next
}
}
onMounted(() => {
document.addEventListener('click', closeLightSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeLightSlider)
})
</script>

View File

@@ -11,53 +11,55 @@
<i class="pi pi-table text-lg text-base-foreground" />
</Button>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<input
ref="colorPickerRef"
type="color"
:value="backgroundColor"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
/>
</Button>
</div>
<template v-if="!hdriActive">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<input
ref="colorPickerRef"
type="color"
:value="backgroundColor"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@input="
updateBackgroundColor(($event.target as HTMLInputElement).value)
"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@change="uploadBackgroundImage"
/>
</Button>
</div>
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
variant="textonly"
size="icon"
class="rounded-full"
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<input
ref="imagePickerRef"
type="file"
accept="image/*"
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
@change="uploadBackgroundImage"
/>
</Button>
</div>
</template>
<div v-if="hasBackgroundImage">
<Button
@@ -112,6 +114,10 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
}>()
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
}>()

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -6,8 +6,11 @@ import RangeEditor from './RangeEditor.vue'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
return mount(RangeEditor, {
function renderEditor(props: {
modelValue: { min: number; max: number; midpoint?: number }
[key: string]: unknown
}) {
return render(RangeEditor, {
props,
global: { plugins: [i18n] }
})
@@ -15,20 +18,19 @@ function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
describe('RangeEditor', () => {
it('renders with min and max handles', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
expect(screen.getByTestId('handle-min')).toBeDefined()
expect(screen.getByTestId('handle-max')).toBeDefined()
})
it('highlights selected range in plain mode', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
const highlight = wrapper.find('[data-testid="range-highlight"]')
expect(highlight.attributes('x')).toBe('0.2')
const highlight = screen.getByTestId('range-highlight')
expect(highlight.getAttribute('x')).toBe('0.2')
expect(
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
Number.parseFloat(highlight.getAttribute('width') ?? 'NaN')
).toBeCloseTo(0.6, 6)
})
@@ -37,37 +39,37 @@ describe('RangeEditor', () => {
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0.2, max: 0.8 },
display: 'histogram',
histogram
})
const left = wrapper.find('[data-testid="range-dim-left"]')
const right = wrapper.find('[data-testid="range-dim-right"]')
expect(left.attributes('width')).toBe('0.2')
expect(right.attributes('x')).toBe('0.8')
const left = screen.getByTestId('range-dim-left')
const right = screen.getByTestId('range-dim-right')
expect(left.getAttribute('width')).toBe('0.2')
expect(right.getAttribute('x')).toBe('0.8')
})
it('hides midpoint handle by default', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 }
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
expect(screen.queryByTestId('handle-midpoint')).toBeNull()
})
it('shows midpoint handle when showMidpoint is true', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
expect(screen.getByTestId('handle-midpoint')).toBeDefined()
})
it('renders gradient background when display is gradient', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1 },
display: 'gradient',
gradientStops: [
@@ -76,8 +78,8 @@ describe('RangeEditor', () => {
]
})
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
expect(wrapper.find('linearGradient').exists()).toBe(true)
expect(screen.getByTestId('gradient-bg')).toBeDefined()
expect(screen.getByTestId('gradient-def')).toBeDefined()
})
it('renders histogram path when display is histogram with data', () => {
@@ -85,47 +87,43 @@ describe('RangeEditor', () => {
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1 },
display: 'histogram',
histogram
})
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
expect(screen.getByTestId('histogram-path')).toBeDefined()
})
it('renders inputs for min and max', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
renderEditor({ modelValue: { min: 0.2, max: 0.8 } })
const inputs = wrapper.findAll('input')
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(2)
})
it('renders midpoint input when showMidpoint is true', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
const inputs = wrapper.findAll('input')
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(3)
})
it('normalizes handle positions with custom value range', () => {
const wrapper = mountEditor({
renderEditor({
modelValue: { min: 64, max: 192 },
valueMin: 0,
valueMax: 255
})
const minHandle = wrapper.find('[data-testid="handle-min"]')
const maxHandle = wrapper.find('[data-testid="handle-max"]')
const minHandle = screen.getByTestId('handle-min')
const maxHandle = screen.getByTestId('handle-max')
expect(
Number.parseFloat((minHandle.element as HTMLElement).style.left)
).toBeCloseTo(25, 0)
expect(
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
).toBeCloseTo(75, 0)
expect(Number.parseFloat(minHandle.style.left)).toBeCloseTo(25, 0)
expect(Number.parseFloat(maxHandle.style.left)).toBeCloseTo(75, 0)
})
})

View File

@@ -17,7 +17,14 @@
"
>
<defs v-if="display === 'gradient'">
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
<linearGradient
:id="gradientId"
data-testid="gradient-def"
x1="0"
y1="0"
x2="1"
y2="0"
>
<stop
v-for="(stop, i) in computedStops"
:key="i"

View File

@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import type { Point } from '@/extensions/core/maskeditor/types'
import { rgbToHsl } from '@/utils/colorUtil'
const getPixelAlpha = (
data: Uint8ClampedArray,
@@ -47,39 +48,8 @@ const rgbToHSL = (
g: number,
b: number
): { h: number; s: number; l: number } => {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
const hsl = rgbToHsl({ r, g, b })
return { h: hsl.h * 360, s: hsl.s * 100, l: hsl.l * 100 }
}
const rgbToLab = (rgb: {

View File

@@ -23,7 +23,17 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn(),
uploadFile: vi.fn()
uploadFile: vi.fn(),
mapSceneLightIntensityToHdri: vi.fn(
(scene: number, min: number, max: number) => {
const span = max - min
const t = span > 0 ? (scene - min) / span : 0
const clampedT = Math.min(1, Math.max(0, t))
const mapped = clampedT * 5
const minHdri = 0.25
return Math.min(5, Math.max(minHdri, mapped))
}
)
}
}))
@@ -72,7 +82,13 @@ describe('useLoad3d', () => {
state: null
},
'Light Config': {
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
},
'Resource Folder': ''
},
@@ -122,6 +138,11 @@ describe('useLoad3d', () => {
isPlyModel: vi.fn().mockReturnValue(false),
hasSkeleton: vi.fn().mockReturnValue(false),
setShowSkeleton: vi.fn(),
loadHDRI: vi.fn().mockResolvedValue(undefined),
setHDRIEnabled: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIIntensity: vi.fn(),
clearHDRI: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
@@ -167,7 +188,13 @@ describe('useLoad3d', () => {
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
@@ -476,7 +503,7 @@ describe('useLoad3d', () => {
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
expect(mockNode.properties['Light Config']).toEqual({
expect(mockNode.properties['Light Config']).toMatchObject({
intensity: 10
})
})
@@ -912,6 +939,97 @@ describe('useLoad3d', () => {
})
})
describe('hdri controls', () => {
it('should call setHDRIEnabled when hdriConfig.enabled changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
}
await nextTick()
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
})
it('should call setHDRIAsBackground when hdriConfig.showAsBackground changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, showAsBackground: true }
}
await nextTick()
expect(mockLoad3d.setHDRIAsBackground).toHaveBeenCalledWith(true)
})
it('should call setHDRIIntensity when hdriConfig.intensity changes', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: { ...composable.lightConfig.value.hdri!, intensity: 2.5 }
}
await nextTick()
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
})
it('should upload file, load HDRI and update hdriConfig', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/view?filename=env.hdr'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/view?filename=env.hdr'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'env.hdr', { type: 'image/x-hdr' })
await composable.handleHDRIFileUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
'http://localhost/view?filename=env.hdr'
)
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('3d/env.hdr')
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
})
it('should clear HDRI when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value = {
...composable.lightConfig.value,
hdri: {
enabled: true,
hdriPath: '3d/env.hdr',
showAsBackground: true,
intensity: 1
}
}
await composable.handleHDRIFileUpdate(null)
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
})
})
describe('edge cases', () => {
it('should handle null node ref', () => {
const nodeRef = ref(null)

View File

@@ -1,6 +1,7 @@
import type { MaybeRef } from 'vue'
import { toRef } from '@vueuse/core'
import { getActivePinia } from 'pinia'
import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
@@ -24,6 +25,7 @@ import type {
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -58,8 +60,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
})
const lightConfig = ref<LightConfig>({
intensity: 5
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
})
const lastNonHdriLightIntensity = ref(lightConfig.value.intensity)
const isRecording = ref(false)
const hasRecording = ref(false)
@@ -185,8 +194,45 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
const savedLightConfig = node.properties['Light Config'] as LightConfig
const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false
if (savedLightConfig) {
lightConfig.value = savedLightConfig
lightConfig.value = {
intensity: savedLightConfig.intensity ?? lightConfig.value.intensity,
hdri: {
...lightConfig.value.hdri!,
...savedLightConfig.hdri,
enabled: false
}
}
lastNonHdriLightIntensity.value = lightConfig.value.intensity
}
const hdri = lightConfig.value.hdri
let hdriLoaded = false
if (hdri?.hdriPath) {
const hdriUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(hdri.hdriPath),
'input'
)
)
try {
await load3d.loadHDRI(hdriUrl)
hdriLoaded = true
} catch (error) {
console.warn('Failed to restore HDRI:', error)
lightConfig.value = {
...lightConfig.value,
hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false }
}
}
}
if (hdriLoaded && savedHdriEnabled) {
lightConfig.value = {
...lightConfig.value,
hdri: { ...lightConfig.value.hdri!, enabled: true }
}
}
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
@@ -213,6 +259,39 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
} else if (cameraStateToRestore) {
load3d.setCameraState(cameraStateToRestore)
}
applySceneConfigToLoad3d()
applyLightConfigToLoad3d()
}
const applySceneConfigToLoad3d = () => {
if (!load3d) return
const cfg = sceneConfig.value
load3d.toggleGrid(cfg.showGrid)
if (!lightConfig.value.hdri?.enabled) {
load3d.setBackgroundColor(cfg.backgroundColor)
}
if (cfg.backgroundRenderMode) {
load3d.setBackgroundRenderMode(cfg.backgroundRenderMode)
}
}
const applyLightConfigToLoad3d = () => {
if (!load3d) return
const cfg = lightConfig.value
load3d.setLightIntensity(cfg.intensity)
const hdri = cfg.hdri
if (!hdri) return
load3d.setHDRIIntensity(hdri.intensity)
load3d.setHDRIAsBackground(hdri.showAsBackground)
load3d.setHDRIEnabled(hdri.enabled)
}
const persistLightConfigToNode = () => {
const n = nodeRef.value
if (n) {
n.properties['Light Config'] = lightConfig.value
}
}
const getModelUrl = (modelPath: string): string | null => {
@@ -260,22 +339,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
watch(
sceneConfig,
async (newValue) => {
if (load3d && nodeRef.value) {
(newValue) => {
if (nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
load3d.toggleGrid(newValue.showGrid)
load3d.setBackgroundColor(newValue.backgroundColor)
await load3d.setBackgroundImage(newValue.backgroundImage || '')
if (newValue.backgroundRenderMode) {
load3d.setBackgroundRenderMode(newValue.backgroundRenderMode)
}
}
},
{ deep: true }
)
watch(
() => sceneConfig.value.showGrid,
(showGrid) => {
load3d?.toggleGrid(showGrid)
}
)
watch(
() => sceneConfig.value.backgroundColor,
(color) => {
if (!load3d || lightConfig.value.hdri?.enabled) return
load3d.setBackgroundColor(color)
}
)
watch(
() => sceneConfig.value.backgroundImage,
async (image) => {
if (!load3d) return
await load3d.setBackgroundImage(image || '')
}
)
watch(
() => sceneConfig.value.backgroundRenderMode,
(mode) => {
if (mode) load3d?.setBackgroundRenderMode(mode)
}
)
watch(
modelConfig,
(newValue) => {
@@ -302,14 +403,54 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
)
watch(
lightConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Light Config'] = newValue
load3d.setLightIntensity(newValue.intensity)
() => lightConfig.value.intensity,
(intensity) => {
if (!load3d || !nodeRef.value) return
if (!lightConfig.value.hdri?.enabled) {
lastNonHdriLightIntensity.value = intensity
}
},
{ deep: true }
persistLightConfigToNode()
load3d.setLightIntensity(intensity)
}
)
watch(
() => lightConfig.value.hdri?.intensity,
(intensity) => {
if (!load3d || !nodeRef.value) return
if (intensity === undefined) return
persistLightConfigToNode()
load3d.setHDRIIntensity(intensity)
}
)
watch(
() => lightConfig.value.hdri?.showAsBackground,
(show) => {
if (!load3d || !nodeRef.value) return
if (show === undefined) return
persistLightConfigToNode()
load3d.setHDRIAsBackground(show)
}
)
watch(
() => lightConfig.value.hdri?.enabled,
(enabled, prevEnabled) => {
if (!load3d || !nodeRef.value) return
if (enabled === undefined) return
if (enabled && prevEnabled === false) {
lastNonHdriLightIntensity.value = lightConfig.value.intensity
}
if (!enabled && prevEnabled === true) {
lightConfig.value = {
...lightConfig.value,
intensity: lastNonHdriLightIntensity.value
}
}
persistLightConfigToNode()
load3d.setHDRIEnabled(enabled)
}
)
watch(playing, (newValue) => {
@@ -377,6 +518,98 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const handleHDRIFileUpdate = async (file: File | null) => {
const capturedLoad3d = load3d
if (!capturedLoad3d) return
if (!file) {
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: '',
enabled: false,
showAsBackground: false
}
}
capturedLoad3d.clearHDRI()
return
}
const resourceFolder =
(nodeRef.value?.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) {
return
}
// Re-validate: node may have been removed during upload
if (load3d !== capturedLoad3d) return
const hdriUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadedPath),
'input'
)
)
try {
loading.value = true
loadingMessage.value = t('load3d.loadingHDRI')
await capturedLoad3d.loadHDRI(hdriUrl)
if (load3d !== capturedLoad3d) return
let sceneMin = 1
let sceneMax = 10
if (getActivePinia() != null) {
const settingStore = useSettingStore()
sceneMin = settingStore.get(
'Comfy.Load3D.LightIntensityMinimum'
) as number
sceneMax = settingStore.get(
'Comfy.Load3D.LightIntensityMaximum'
) as number
}
const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri(
lightConfig.value.intensity,
sceneMin,
sceneMax
)
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: uploadedPath,
enabled: true,
showAsBackground: true,
intensity: mappedHdriIntensity
}
}
} catch (error) {
console.error('Failed to load HDRI:', error)
capturedLoad3d.clearHDRI()
lightConfig.value = {
...lightConfig.value,
hdri: {
...lightConfig.value.hdri!,
hdriPath: '',
enabled: false,
showAsBackground: false
}
}
useToastStore().addAlert(t('toastMessages.failedToLoadHDRI'))
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
sceneConfig.value.backgroundImage = ''
@@ -642,6 +875,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
cleanup

View File

@@ -0,0 +1,223 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HDRIManager } from './HDRIManager'
import Load3dUtils from './Load3dUtils'
const { mockFromEquirectangular, mockDisposePMREM } = vi.hoisted(() => ({
mockFromEquirectangular: vi.fn(),
mockDisposePMREM: vi.fn()
}))
vi.mock('./Load3dUtils', () => ({
default: {
getFilenameExtension: vi.fn()
}
}))
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class MockPMREMGenerator {
compileEquirectangularShader = vi.fn()
fromEquirectangular = mockFromEquirectangular
dispose = mockDisposePMREM
}
return { ...actual, PMREMGenerator: MockPMREMGenerator }
})
vi.mock('three/examples/jsm/loaders/EXRLoader', () => {
class EXRLoader {
load(
_url: string,
resolve: (t: THREE.Texture) => void,
_onProgress: undefined,
_reject: (e: unknown) => void
) {
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
}
}
return { EXRLoader }
})
vi.mock('three/examples/jsm/loaders/RGBELoader', () => {
class RGBELoader {
load(
_url: string,
resolve: (t: THREE.Texture) => void,
_onProgress: undefined,
_reject: (e: unknown) => void
) {
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
}
}
return { RGBELoader }
})
function makeMockEventManager() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
describe('HDRIManager', () => {
let scene: THREE.Scene
let eventManager: ReturnType<typeof makeMockEventManager>
let manager: HDRIManager
beforeEach(() => {
vi.clearAllMocks()
scene = new THREE.Scene()
eventManager = makeMockEventManager()
mockFromEquirectangular.mockReturnValue({
texture: new THREE.Texture(),
dispose: vi.fn()
})
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts disabled with default intensity', () => {
expect(manager.isEnabled).toBe(false)
expect(manager.showAsBackground).toBe(false)
expect(manager.intensity).toBe(1)
})
})
describe('loadHDRI', () => {
it('loads .exr files without error', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('exr')
await expect(
manager.loadHDRI('http://example.com/env.exr')
).resolves.toBeUndefined()
})
it('loads .hdr files without error', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await expect(
manager.loadHDRI('http://example.com/env.hdr')
).resolves.toBeUndefined()
})
it('applies to scene immediately when already enabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
manager.setEnabled(true)
// No texture loaded yet so scene.environment stays null
expect(scene.environment).toBeNull()
await manager.loadHDRI('http://example.com/env.hdr')
expect(scene.environment).not.toBeNull()
})
it('does not apply to scene when disabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
expect(scene.environment).toBeNull()
})
})
describe('setEnabled', () => {
it('applies environment map to scene when enabled after loading', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
expect(scene.environment).not.toBeNull()
expect(eventManager.emitEvent).toHaveBeenCalledWith('hdriChange', {
enabled: true,
showAsBackground: false
})
})
it('removes environment map from scene when disabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setEnabled(false)
expect(scene.environment).toBeNull()
expect(eventManager.emitEvent).toHaveBeenLastCalledWith('hdriChange', {
enabled: false,
showAsBackground: false
})
})
})
describe('setIntensity', () => {
it('updates scene intensity when enabled', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setIntensity(2.5)
expect(scene.environmentIntensity).toBe(2.5)
expect(manager.intensity).toBe(2.5)
})
it('stores intensity without applying when disabled', () => {
manager.setIntensity(3)
expect(manager.intensity).toBe(3)
expect(scene.environmentIntensity).not.toBe(3)
})
})
describe('setShowAsBackground', () => {
it('sets scene background texture when enabled and showing as background', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setShowAsBackground(true)
expect(scene.background).not.toBeNull()
})
it('clears scene background when showAsBackground is false', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.setShowAsBackground(true)
manager.setShowAsBackground(false)
expect(scene.background).toBeNull()
})
})
describe('clear', () => {
it('removes HDRI from scene and resets state', async () => {
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
await manager.loadHDRI('http://example.com/env.hdr')
manager.setEnabled(true)
manager.clear()
expect(manager.isEnabled).toBe(false)
expect(scene.environment).toBeNull()
})
})
describe('dispose', () => {
it('disposes PMREMGenerator', () => {
manager.dispose()
expect(mockDisposePMREM).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,142 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import Load3dUtils from './Load3dUtils'
import type { EventManagerInterface } from './interfaces'
export class HDRIManager {
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private pmremGenerator: THREE.PMREMGenerator
private eventManager: EventManagerInterface
private hdriTexture: THREE.Texture | null = null
private envMapTarget: THREE.WebGLRenderTarget | null = null
private _isEnabled: boolean = false
private _showAsBackground: boolean = false
private _intensity: number = 1
get isEnabled() {
return this._isEnabled
}
get showAsBackground() {
return this._showAsBackground
}
get intensity() {
return this._intensity
}
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface
) {
this.scene = scene
this.renderer = renderer
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
this.pmremGenerator.compileEquirectangularShader()
this.eventManager = eventManager
}
async loadHDRI(url: string): Promise<void> {
const ext = Load3dUtils.getFilenameExtension(url)
let newTexture: THREE.Texture
if (ext === 'exr') {
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
new EXRLoader().load(url, resolve, undefined, reject)
})
} else {
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
new RGBELoader().load(url, resolve, undefined, reject)
})
}
newTexture.mapping = THREE.EquirectangularReflectionMapping
const newEnvMapTarget = this.pmremGenerator.fromEquirectangular(newTexture)
// Dispose old resources only after the new one is ready
this.hdriTexture?.dispose()
this.envMapTarget?.dispose()
this.hdriTexture = newTexture
this.envMapTarget = newEnvMapTarget
if (this._isEnabled) {
this.applyToScene()
}
}
setEnabled(enabled: boolean): void {
this._isEnabled = enabled
if (enabled) {
if (this.envMapTarget) {
this.applyToScene()
}
} else {
this.removeFromScene()
}
}
setShowAsBackground(show: boolean): void {
this._showAsBackground = show
if (this._isEnabled && this.envMapTarget) {
this.applyToScene()
}
}
setIntensity(intensity: number): void {
this._intensity = intensity
if (this._isEnabled) {
this.scene.environmentIntensity = intensity
}
}
private applyToScene(): void {
const envMap = this.envMapTarget?.texture
if (!envMap) return
this.scene.environment = envMap
this.scene.environmentIntensity = this._intensity
this.scene.background = this._showAsBackground ? this.hdriTexture : null
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: this._isEnabled,
showAsBackground: this._showAsBackground
})
}
private removeFromScene(): void {
this.scene.environment = null
if (this.scene.background === this.hdriTexture) {
this.scene.background = null
}
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: false,
showAsBackground: this._showAsBackground
})
}
private clearResources(): void {
this.removeFromScene()
this.hdriTexture?.dispose()
this.envMapTarget?.dispose()
this.hdriTexture = null
this.envMapTarget = null
}
clear(): void {
this.clearResources()
this._isEnabled = false
}
dispose(): void {
this.clearResources()
this.pmremGenerator.dispose()
}
}

View File

@@ -10,6 +10,7 @@ export class LightingManager implements LightingManagerInterface {
currentIntensity: number = 3
private scene: THREE.Scene
private eventManager: EventManagerInterface
private lightMultipliers = new Map<THREE.Light, number>()
constructor(scene: THREE.Scene, eventManager: EventManagerInterface) {
this.scene = scene
@@ -25,59 +26,53 @@ export class LightingManager implements LightingManagerInterface {
this.scene.remove(light)
})
this.lights = []
this.lightMultipliers.clear()
}
setupLights(): void {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
this.scene.add(ambientLight)
this.lights.push(ambientLight)
const addLight = (light: THREE.Light, multiplier: number) => {
this.scene.add(light)
this.lights.push(light)
this.lightMultipliers.set(light, multiplier)
}
addLight(new THREE.AmbientLight(0xffffff, 0.5), 0.5)
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
mainLight.position.set(0, 10, 10)
this.scene.add(mainLight)
this.lights.push(mainLight)
addLight(mainLight, 0.8)
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
backLight.position.set(0, 10, -10)
this.scene.add(backLight)
this.lights.push(backLight)
addLight(backLight, 0.5)
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
leftFillLight.position.set(-10, 0, 0)
this.scene.add(leftFillLight)
this.lights.push(leftFillLight)
addLight(leftFillLight, 0.3)
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
rightFillLight.position.set(10, 0, 0)
this.scene.add(rightFillLight)
this.lights.push(rightFillLight)
addLight(rightFillLight, 0.3)
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
bottomLight.position.set(0, -10, 0)
this.scene.add(bottomLight)
this.lights.push(bottomLight)
addLight(bottomLight, 0.2)
}
setLightIntensity(intensity: number): void {
this.currentIntensity = intensity
this.lights.forEach((light) => {
if (light instanceof THREE.DirectionalLight) {
if (light === this.lights[1]) {
light.intensity = intensity * 0.8
} else if (light === this.lights[2]) {
light.intensity = intensity * 0.5
} else if (light === this.lights[5]) {
light.intensity = intensity * 0.2
} else {
light.intensity = intensity * 0.3
}
} else if (light instanceof THREE.AmbientLight) {
light.intensity = intensity * 0.5
}
light.intensity = intensity * (this.lightMultipliers.get(light) ?? 1)
})
this.eventManager.emitEvent('lightIntensityChange', intensity)
}
setHDRIMode(hdriActive: boolean): void {
this.lights.forEach((light) => {
light.visible = !hdriActive
})
}
reset(): void {}
}

View File

@@ -3,6 +3,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
CameraState,
HDRIConfig,
LightConfig,
ModelConfig,
SceneConfig
@@ -113,6 +114,7 @@ class Load3DConfiguration {
const lightConfig = this.loadLightConfig()
this.applyLightConfig(lightConfig)
if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri)
}
private loadSceneConfig(): SceneConfig {
@@ -140,13 +142,27 @@ class Load3DConfiguration {
}
private loadLightConfig(): LightConfig {
const hdriDefaults: HDRIConfig = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
if (this.properties && 'Light Config' in this.properties) {
return this.properties['Light Config'] as LightConfig
const saved = this.properties['Light Config'] as Partial<LightConfig>
return {
intensity:
saved.intensity ??
(useSettingStore().get('Comfy.Load3D.LightIntensity') as number),
hdri: { ...hdriDefaults, ...(saved.hdri ?? {}) }
}
}
return {
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
} as LightConfig
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number,
hdri: hdriDefaults
}
}
private loadModelConfig(): ModelConfig {
@@ -190,6 +206,15 @@ class Load3DConfiguration {
this.load3d.setLightIntensity(config.intensity)
}
private applyHDRISettings(config: HDRIConfig) {
if (!config.hdriPath) return
this.load3d.setHDRIIntensity(config.intensity)
this.load3d.setHDRIAsBackground(config.showAsBackground)
if (config.enabled) {
this.load3d.setHDRIEnabled(true)
}
}
private applyModelConfig(config: ModelConfig) {
this.load3d.setUpDirection(config.upDirection)
this.load3d.setMaterialMode(config.materialMode)

View File

@@ -6,6 +6,7 @@ import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { HDRIManager } from './HDRIManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
@@ -54,6 +55,7 @@ class Load3d {
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
@@ -126,6 +128,12 @@ class Load3d {
this.eventManager
)
this.hdriManager = new HDRIManager(
this.sceneManager.scene,
this.renderer,
this.eventManager
)
this.viewHelperManager = new ViewHelperManager(
this.renderer,
this.getActiveCamera.bind(this),
@@ -635,6 +643,33 @@ class Load3d {
this.forceRender()
}
async loadHDRI(url: string): Promise<void> {
await this.hdriManager.loadHDRI(url)
this.forceRender()
}
setHDRIEnabled(enabled: boolean): void {
this.hdriManager.setEnabled(enabled)
this.lightingManager.setHDRIMode(enabled)
this.forceRender()
}
setHDRIAsBackground(show: boolean): void {
this.hdriManager.setShowAsBackground(show)
this.forceRender()
}
setHDRIIntensity(intensity: number): void {
this.hdriManager.setIntensity(intensity)
this.forceRender()
}
clearHDRI(): void {
this.hdriManager.clear()
this.lightingManager.setHDRIMode(false)
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
@@ -858,6 +893,7 @@ class Load3d {
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
this.hdriManager.dispose()
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
it('maps scene slider low end to a small positive HDRI intensity', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(1, 1, 10)).toBe(0.25)
expect(Load3dUtils.mapSceneLightIntensityToHdri(10, 1, 10)).toBe(5)
})
it('maps midpoint proportionally', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(5.5, 1, 10)).toBeCloseTo(
2.5
)
})
it('clamps scene ratio and HDRI ceiling', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(-10, 1, 10)).toBe(0.25)
expect(Load3dUtils.mapSceneLightIntensityToHdri(100, 1, 10)).toBe(5)
})
it('uses minimum HDRI when span is zero', () => {
expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25)
})
})

View File

@@ -89,6 +89,15 @@ class Load3dUtils {
return uploadPath
}
static getFilenameExtension(url: string): string | undefined {
const queryString = url.split('?')[1]
if (queryString) {
const filename = new URLSearchParams(queryString).get('filename')
if (filename) return filename.split('.').pop()?.toLowerCase()
}
return url.split('?')[0].split('.').pop()?.toLowerCase()
}
static splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
@@ -122,6 +131,19 @@ class Load3dUtils {
await Promise.all(uploadPromises)
}
static mapSceneLightIntensityToHdri(
sceneIntensity: number,
sceneMin: number,
sceneMax: number
): number {
const span = sceneMax - sceneMin
const t = span > 0 ? (sceneIntensity - sceneMin) / span : 0
const clampedT = Math.min(1, Math.max(0, t))
const mapped = clampedT * 5
const minHdri = 0.25
return Math.min(5, Math.max(minHdri, mapped))
}
}
export default Load3dUtils

View File

@@ -16,3 +16,9 @@ export const SUPPORTED_EXTENSIONS = new Set([
])
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
...SUPPORTED_HDRI_EXTENSIONS
].join(',')

View File

@@ -47,6 +47,14 @@ export interface CameraConfig {
export interface LightConfig {
intensity: number
hdri?: HDRIConfig
}
export interface HDRIConfig {
enabled: boolean
hdriPath: string
showAsBackground: boolean
intensity: number
}
export interface EventCallback<T = unknown> {

View File

@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeLayout } from '@/renderer/core/layout/types'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
class TestNode extends LGraphNode {
static override type = TEST_NODE_TYPE
constructor(title?: string) {
super(title ?? TEST_NODE_TYPE)
this.type = TEST_NODE_TYPE
}
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
function createLayoutEntry(node: LGraphNode, zIndex: number) {
const nodeId = String(node.id)
const layout: NodeLayout = {
id: nodeId,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex,
visible: true,
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex,
previousZIndex,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
}
describe('cloned node z-index in Vue renderer', () => {
let graph: LGraph
let canvas: LGraphCanvas
let previousVueNodesMode: boolean
beforeEach(() => {
vi.clearAllMocks()
previousVueNodesMode = LiteGraph.vueNodesMode
LiteGraph.vueNodesMode = true
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
graph = new LGraph()
canvas = createCanvas(graph)
LGraphCanvas.active_canvas = canvas
layoutStore.initializeFromLiteGraph([])
// Simulate Vue runtime: create layout entries when nodes are added
graph.onNodeAdded = (node: LGraphNode) => {
createLayoutEntry(node, 0)
}
})
afterEach(() => {
LiteGraph.vueNodesMode = previousVueNodesMode
})
it('places cloned nodes above the original node z-index', () => {
const originalNode = new TestNode()
originalNode.pos = [100, 100]
originalNode.size = [200, 100]
graph.add(originalNode)
const originalNodeId = String(originalNode.id)
setZIndex(originalNodeId, 5, 0)
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
expect(originalLayout?.zIndex).toBe(5)
// Clone the node via cloneNodes (same path as right-click > clone)
const result = LGraphCanvas.cloneNodes([originalNode])
expect(result).toBeDefined()
expect(result!.created.length).toBe(1)
const clonedNode = result!.created[0] as LGraphNode
const clonedNodeId = String(clonedNode.id)
// The cloned node should have a z-index higher than the original
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
expect(clonedLayout).toBeDefined()
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
})
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
const nodeA = new TestNode()
nodeA.pos = [100, 100]
nodeA.size = [200, 100]
graph.add(nodeA)
setZIndex(String(nodeA.id), 3, 0)
const nodeB = new TestNode()
nodeB.pos = [400, 100]
nodeB.size = [200, 100]
graph.add(nodeB)
setZIndex(String(nodeB.id), 7, 0)
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
expect(result).toBeDefined()
expect(result!.created.length).toBe(2)
const clonedA = result!.created[0] as LGraphNode
const clonedB = result!.created[1] as LGraphNode
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
// Both cloned nodes should be above the highest original (z-index 7)
expect(layoutA.zIndex).toBeGreaterThan(7)
expect(layoutB.zIndex).toBeGreaterThan(7)
// Each cloned node should have a distinct z-index
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
})
})

View File

@@ -7,6 +7,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -4270,6 +4271,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
// Bring cloned/pasted nodes to front so they render above the originals
const allNodes = layoutStore.getAllNodes().value
let maxZIndex = 0
for (const [, layout] of allNodes) {
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
}
const { setNodeZIndex } = useLayoutMutations()
for (let i = 0; i < newPositions.length; i++) {
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
}
this.selectItems(created)
forEachNode(graph, (n) => n.onGraphConfigured?.())
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "تصدير التسجيل",
"exportingModel": "جارٍ تصدير النموذج...",
"fov": "مجال الرؤية (FOV)",
"hdri": {
"changeFile": "تغيير HDRI",
"intensity": "الشدة",
"label": "بيئة HDRI",
"removeFile": "إزالة HDRI",
"showAsBackground": "عرض كخلفية",
"uploadFile": "رفع HDRI (.hdr, .exr)"
},
"light": "الإضاءة",
"lightIntensity": "شدة الإضاءة",
"loadingBackgroundImage": "جارٍ تحميل صورة الخلفية",
"loadingHDRI": "جارٍ تحميل HDRI...",
"loadingModel": "جارٍ تحميل النموذج ثلاثي الأبعاد...",
"materialMode": "وضع المادة",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "فشل في بدء شراء الرصيد: {error}",
"failedToInitiateSubscription": "فشل في بدء الاشتراك: {error}",
"failedToLoadBackgroundImage": "فشل في تحميل صورة الخلفية",
"failedToLoadHDRI": "فشل في تحميل ملف HDRI",
"failedToLoadModel": "فشل في تحميل النموذج ثلاثي الأبعاد",
"failedToPurchaseCredits": "فشل في شراء الرصيد: {error}",
"failedToQueue": "فشل في الإضافة إلى قائمة الانتظار",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "يرجى اختيار عقد الإخراج",
"unableToGetModelFilePath": "غير قادر على الحصول على مسار ملف النموذج",
"unauthorizedDomain": "النطاق الخاص بك {domain} غير مخول لاستخدام هذه الخدمة. يرجى الاتصال بـ {email} لإضافة النطاق إلى القائمة البيضاء.",
"unsupportedHDRIFormat": "تنسيق الملف غير مدعوم. يرجى رفع ملف .hdr أو .exr.",
"updateRequested": "تم طلب التحديث",
"useApiKeyTip": "نصيحة: لا يمكنك الدخول عبر تسجيل الدخول العادي؟ استخدم خيار مفتاح API الخاص بـ Comfy.",
"userNotAuthenticated": "المستخدم غير مصدق"

View File

@@ -1988,7 +1988,16 @@
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)",
"uploadingModel": "Uploading 3D model..."
"uploadingModel": "Uploading 3D model...",
"loadingHDRI": "Loading HDRI...",
"hdri": {
"label": "HDRI Environment",
"uploadFile": "Upload HDRI (.hdr, .exr)",
"changeFile": "Change HDRI",
"removeFile": "Remove HDRI",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
}
},
"imageCrop": {
"loading": "Loading...",
@@ -2083,7 +2092,9 @@
"failedToUpdateMaterialMode": "Failed to update material mode",
"failedToUpdateEdgeThreshold": "Failed to update edge threshold",
"failedToUploadBackgroundImage": "Failed to upload background image",
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}"
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}",
"failedToLoadHDRI": "Failed to load HDRI file",
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
},
"nodeErrors": {
"render": "Node Render Error",

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "Exportar grabación",
"exportingModel": "Exportando modelo...",
"fov": "FOV",
"hdri": {
"changeFile": "Cambiar HDRI",
"intensity": "Intensidad",
"label": "Entorno HDRI",
"removeFile": "Eliminar HDRI",
"showAsBackground": "Mostrar como fondo",
"uploadFile": "Subir HDRI (.hdr, .exr)"
},
"light": "Luz",
"lightIntensity": "Intensidad de luz",
"loadingBackgroundImage": "Cargando imagen de fondo",
"loadingHDRI": "Cargando HDRI...",
"loadingModel": "Cargando modelo 3D...",
"materialMode": "Modo de material",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "No se pudo iniciar la compra de créditos: {error}",
"failedToInitiateSubscription": "Error al iniciar la suscripción: {error}",
"failedToLoadBackgroundImage": "Error al cargar la imagen de fondo",
"failedToLoadHDRI": "No se pudo cargar el archivo HDRI",
"failedToLoadModel": "Error al cargar el modelo 3D",
"failedToPurchaseCredits": "No se pudo comprar créditos: {error}",
"failedToQueue": "Error al encolar",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida",
"unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo",
"unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.",
"unsupportedHDRIFormat": "Formato de archivo no compatible. Por favor, sube un archivo .hdr o .exr.",
"updateRequested": "Actualización solicitada",
"useApiKeyTip": "Consejo: ¿No puedes acceder al inicio de sesión normal? Usa la opción de clave API de Comfy.",
"userNotAuthenticated": "Usuario no autenticado"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "خروجی گرفتن ضبط",
"exportingModel": "در حال خروجی گرفتن مدل...",
"fov": "زاویه دید (FOV)",
"hdri": {
"changeFile": "تغییر HDRI",
"intensity": "شدت",
"label": "محیط HDRI",
"removeFile": "حذف HDRI",
"showAsBackground": "نمایش به عنوان پس‌زمینه",
"uploadFile": "بارگذاری HDRI (.hdr، .exr)"
},
"light": "نور",
"lightIntensity": "شدت نور",
"loadingBackgroundImage": "در حال بارگذاری تصویر پس‌زمینه",
"loadingHDRI": "در حال بارگذاری HDRI...",
"loadingModel": "در حال بارگذاری مدل سه‌بعدی...",
"materialMode": "حالت متریال",
"materialModes": {
@@ -3443,6 +3452,7 @@
"failedToInitiateCreditPurchase": "آغاز خرید اعتبار انجام نشد: {error}",
"failedToInitiateSubscription": "آغاز اشتراک انجام نشد: {error}",
"failedToLoadBackgroundImage": "بارگذاری تصویر پس‌زمینه انجام نشد",
"failedToLoadHDRI": "بارگذاری فایل HDRI ناموفق بود",
"failedToLoadModel": "بارگذاری مدل سه‌بعدی انجام نشد",
"failedToPurchaseCredits": "خرید اعتبار انجام نشد: {error}",
"failedToQueue": "صف‌بندی انجام نشد",
@@ -3478,6 +3488,7 @@
"pleaseSelectOutputNodes": "لطفاً nodeهای خروجی را انتخاب کنید",
"unableToGetModelFilePath": "امکان دریافت مسیر فایل مدل وجود ندارد",
"unauthorizedDomain": "دامنه شما ({domain}) مجاز به استفاده از این سرویس نیست. لطفاً برای افزودن دامنه خود به لیست سفید با {email} تماس بگیرید.",
"unsupportedHDRIFormat": "فرمت فایل پشتیبانی نمی‌شود. لطفاً یک فایل .hdr یا .exr بارگذاری کنید.",
"updateRequested": "درخواست به‌روزرسانی ثبت شد",
"useApiKeyTip": "نکته: به ورود عادی دسترسی ندارید؟ از گزینه Comfy API Key استفاده کنید.",
"userNotAuthenticated": "کاربر احراز هویت نشده است"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "Exporter l'enregistrement",
"exportingModel": "Exportation du modèle en cours...",
"fov": "FOV",
"hdri": {
"changeFile": "Changer l'HDRI",
"intensity": "Intensité",
"label": "Environnement HDRI",
"removeFile": "Supprimer l'HDRI",
"showAsBackground": "Afficher comme arrière-plan",
"uploadFile": "Télécharger un HDRI (.hdr, .exr)"
},
"light": "Lumière",
"lightIntensity": "Intensité de la lumière",
"loadingBackgroundImage": "Chargement de limage darrière-plan",
"loadingHDRI": "Chargement de l'HDRI...",
"loadingModel": "Chargement du modèle 3D...",
"materialMode": "Mode Matériel",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "Échec de l'initiation de l'achat de crédits : {error}",
"failedToInitiateSubscription": "Échec de l'initialisation de l'abonnement : {error}",
"failedToLoadBackgroundImage": "Échec du chargement de l'image d'arrière-plan",
"failedToLoadHDRI": "Échec du chargement du fichier HDRI",
"failedToLoadModel": "Échec du chargement du modèle 3D",
"failedToPurchaseCredits": "Échec de l'achat de crédits : {error}",
"failedToQueue": "Échec de la mise en file d'attente",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "Veuillez sélectionner les nœuds de sortie",
"unableToGetModelFilePath": "Impossible d'obtenir le chemin du fichier modèle",
"unauthorizedDomain": "Votre domaine {domain} n'est pas autorisé à utiliser ce service. Veuillez contacter {email} pour ajouter votre domaine à la liste blanche.",
"unsupportedHDRIFormat": "Format de fichier non pris en charge. Veuillez télécharger un fichier .hdr ou .exr.",
"updateRequested": "Mise à jour demandée",
"useApiKeyTip": "Astuce : Vous ne pouvez pas accéder à la connexion normale ? Utilisez loption Clé API Comfy.",
"userNotAuthenticated": "Utilisateur non authentifié"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "録画をエクスポート",
"exportingModel": "モデルをエクスポート中...",
"fov": "FOV",
"hdri": {
"changeFile": "HDRIを変更",
"intensity": "強度",
"label": "HDRI環境",
"removeFile": "HDRIを削除",
"showAsBackground": "背景として表示",
"uploadFile": "HDRIをアップロード.hdr、.exr"
},
"light": "ライト",
"lightIntensity": "光の強度",
"loadingBackgroundImage": "背景画像を読み込んでいます",
"loadingHDRI": "HDRIを読み込み中...",
"loadingModel": "3Dモデルを読み込んでいます...",
"materialMode": "マテリアルモード",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "クレジット購入の開始に失敗しました: {error}",
"failedToInitiateSubscription": "サブスクリプションの開始に失敗しました: {error}",
"failedToLoadBackgroundImage": "背景画像の読み込みに失敗しました",
"failedToLoadHDRI": "HDRIファイルの読み込みに失敗しました",
"failedToLoadModel": "3Dモデルの読み込みに失敗しました",
"failedToPurchaseCredits": "クレジットの購入に失敗しました: {error}",
"failedToQueue": "キューに追加できませんでした",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "出力ノードを選択してください",
"unableToGetModelFilePath": "モデルファイルのパスを取得できません",
"unauthorizedDomain": "あなたのドメイン {domain} はこのサービスを利用する権限がありません。ご利用のドメインをホワイトリストに追加するには、{email} までご連絡ください。",
"unsupportedHDRIFormat": "サポートされていないファイル形式です。.hdrまたは.exrファイルをアップロードしてください。",
"updateRequested": "更新が要求されました",
"useApiKeyTip": "ヒント通常のログインにアクセスできませんかComfy APIキーオプションを使用してください。",
"userNotAuthenticated": "ユーザーが認証されていません"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "녹화 내보내기",
"exportingModel": "모델 내보내기 중...",
"fov": "FOV",
"hdri": {
"changeFile": "HDRI 변경",
"intensity": "강도",
"label": "HDRI 환경",
"removeFile": "HDRI 제거",
"showAsBackground": "배경으로 표시",
"uploadFile": "HDRI 업로드 (.hdr, .exr)"
},
"light": "빛",
"lightIntensity": "조명 강도",
"loadingBackgroundImage": "배경 이미지 불러오는 중",
"loadingHDRI": "HDRI 불러오는 중...",
"loadingModel": "3D 모델 로딩 중...",
"materialMode": "재질 모드",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "크레딧 구매를 시작하지 못했습니다: {error}",
"failedToInitiateSubscription": "구독을 시작하지 못함: {error}",
"failedToLoadBackgroundImage": "배경 이미지를 로드하지 못함",
"failedToLoadHDRI": "HDRI 파일을 불러오지 못했습니다",
"failedToLoadModel": "3D 모델을 로드하지 못함",
"failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}",
"failedToQueue": "대기열 추가 실패",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "출력 노드를 선택해 주세요",
"unableToGetModelFilePath": "모델 파일 경로를 가져올 수 없습니다",
"unauthorizedDomain": "귀하의 도메인 {domain}은(는) 이 서비스를 사용할 수 있는 권한이 없습니다. 도메인을 허용 목록에 추가하려면 {email}로 문의해 주세요.",
"unsupportedHDRIFormat": "지원되지 않는 파일 형식입니다. .hdr 또는 .exr 파일을 업로드해 주세요.",
"updateRequested": "업데이트 요청됨",
"useApiKeyTip": "팁: 일반 로그인을 사용할 수 없나요? Comfy API Key 옵션을 사용하세요.",
"userNotAuthenticated": "사용자가 인증되지 않았습니다"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "Exportar Gravação",
"exportingModel": "Exportando modelo...",
"fov": "Campo de Visão (FOV)",
"hdri": {
"changeFile": "Alterar HDRI",
"intensity": "Intensidade",
"label": "Ambiente HDRI",
"removeFile": "Remover HDRI",
"showAsBackground": "Exibir como fundo",
"uploadFile": "Enviar HDRI (.hdr, .exr)"
},
"light": "Luz",
"lightIntensity": "Intensidade da Luz",
"loadingBackgroundImage": "Carregando Imagem de Fundo",
"loadingHDRI": "Carregando HDRI...",
"loadingModel": "Carregando Modelo 3D...",
"materialMode": "Modo de Material",
"materialModes": {
@@ -3443,6 +3452,7 @@
"failedToInitiateCreditPurchase": "Falha ao iniciar compra de créditos: {error}",
"failedToInitiateSubscription": "Falha ao iniciar assinatura: {error}",
"failedToLoadBackgroundImage": "Falha ao carregar imagem de fundo",
"failedToLoadHDRI": "Falha ao carregar o arquivo HDRI",
"failedToLoadModel": "Falha ao carregar modelo 3D",
"failedToPurchaseCredits": "Falha ao comprar créditos: {error}",
"failedToQueue": "Falha ao enfileirar",
@@ -3478,6 +3488,7 @@
"pleaseSelectOutputNodes": "Por favor, selecione os nós de saída",
"unableToGetModelFilePath": "Não foi possível obter o caminho do arquivo do modelo",
"unauthorizedDomain": "Seu domínio {domain} não está autorizado a usar este serviço. Por favor, entre em contato com {email} para adicionar seu domínio à lista de permissões.",
"unsupportedHDRIFormat": "Formato de arquivo não suportado. Por favor, envie um arquivo .hdr ou .exr.",
"updateRequested": "Atualização solicitada",
"useApiKeyTip": "Dica: Não consegue acessar o login normal? Use a opção Comfy API Key.",
"userNotAuthenticated": "Usuário não autenticado"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "Экспортировать запись",
"exportingModel": "Экспорт модели...",
"fov": "Угол обзора",
"hdri": {
"changeFile": "Сменить HDRI",
"intensity": "Интенсивность",
"label": "HDRI-окружение",
"removeFile": "Удалить HDRI",
"showAsBackground": "Показать как фон",
"uploadFile": "Загрузить HDRI (.hdr, .exr)"
},
"light": "Свет",
"lightIntensity": "Интенсивность света",
"loadingBackgroundImage": "Загрузка фонового изображения",
"loadingHDRI": "Загрузка HDRI...",
"loadingModel": "Загрузка 3D модели...",
"materialMode": "Режим Материала",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "Не удалось начать покупку кредитов: {error}",
"failedToInitiateSubscription": "Не удалось инициировать подписку: {error}",
"failedToLoadBackgroundImage": "Не удалось загрузить фоновое изображение",
"failedToLoadHDRI": "Не удалось загрузить файл HDRI",
"failedToLoadModel": "Не удалось загрузить 3D-модель",
"failedToPurchaseCredits": "Не удалось купить кредиты: {error}",
"failedToQueue": "Не удалось поставить в очередь",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "Пожалуйста, выберите выходные узлы",
"unableToGetModelFilePath": "Не удалось получить путь к файлу модели",
"unauthorizedDomain": "Ваш домен {domain} не авторизован для использования этого сервиса. Пожалуйста, свяжитесь с {email}, чтобы добавить ваш домен в белый список.",
"unsupportedHDRIFormat": "Неподдерживаемый формат файла. Пожалуйста, загрузите файл .hdr или .exr.",
"updateRequested": "Запрошено обновление",
"useApiKeyTip": "Совет: Нет доступа к обычному входу? Используйте опцию Comfy API Key.",
"userNotAuthenticated": "Пользователь не аутентифицирован"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "Kaydı Dışa Aktar",
"exportingModel": "Model dışa aktarılıyor...",
"fov": "FOV",
"hdri": {
"changeFile": "HDRI Değiştir",
"intensity": "Yoğunluk",
"label": "HDRI Ortamı",
"removeFile": "HDRI Kaldır",
"showAsBackground": "Arka Plan Olarak Göster",
"uploadFile": "HDRI Yükle (.hdr, .exr)"
},
"light": "Işık",
"lightIntensity": "Işık Yoğunluğu",
"loadingBackgroundImage": "Arka Plan Resmi Yükleniyor",
"loadingHDRI": "HDRI Yükleniyor...",
"loadingModel": "3D Model Yükleniyor...",
"materialMode": "Malzeme Modu",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "Kredi satın alma başlatılamadı: {error}",
"failedToInitiateSubscription": "Abonelik başlatılamadı: {error}",
"failedToLoadBackgroundImage": "Arka plan görseli yüklenemedi",
"failedToLoadHDRI": "HDRI dosyası yüklenemedi",
"failedToLoadModel": "3B model yüklenemedi",
"failedToPurchaseCredits": "Kredi satın alınamadı: {error}",
"failedToQueue": "Kuyruğa alınamadı",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "Lütfen çıktı düğümlerini seçin",
"unableToGetModelFilePath": "Model dosyası yolu alınamıyor",
"unauthorizedDomain": "{domain} alan adınız bu hizmeti kullanma yetkisine sahip değil. Alan adınızı beyaz listeye eklemek için lütfen {email} ile iletişime geçin.",
"unsupportedHDRIFormat": "Desteklenmeyen dosya formatı. Lütfen .hdr veya .exr dosyası yükleyin.",
"updateRequested": "Güncelleme istendi",
"useApiKeyTip": "İpucu: Normal girişe erişemiyor musunuz? Comfy API Anahtarı seçeneğini kullanın.",
"userNotAuthenticated": "Kullanıcı doğrulanmadı"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "匯出錄影",
"exportingModel": "正在匯出模型...",
"fov": "視野角度",
"hdri": {
"changeFile": "更換 HDRI",
"intensity": "強度",
"label": "HDRI 環境",
"removeFile": "移除 HDRI",
"showAsBackground": "作為背景顯示",
"uploadFile": "上傳 HDRI.hdr, .exr"
},
"light": "光源",
"lightIntensity": "光源強度",
"loadingBackgroundImage": "正在載入背景圖片",
"loadingHDRI": "正在載入 HDRI...",
"loadingModel": "正在載入 3D 模型...",
"materialMode": "材質模式",
"materialModes": {
@@ -3431,6 +3440,7 @@
"failedToInitiateCreditPurchase": "啟動點數購買失敗:{error}",
"failedToInitiateSubscription": "無法啟用訂閱:{error}",
"failedToLoadBackgroundImage": "無法載入背景圖片",
"failedToLoadHDRI": "載入 HDRI 檔案失敗",
"failedToLoadModel": "無法載入 3D 模型",
"failedToPurchaseCredits": "購買點數失敗:{error}",
"failedToQueue": "加入佇列失敗",
@@ -3466,6 +3476,7 @@
"pleaseSelectOutputNodes": "請選擇輸出節點",
"unableToGetModelFilePath": "無法取得模型檔案路徑",
"unauthorizedDomain": "您的網域 {domain} 未被授權使用此服務。請聯絡 {email} 以將您的網域加入白名單。",
"unsupportedHDRIFormat": "不支援的檔案格式。請上傳 .hdr 或 .exr 檔案。",
"updateRequested": "已請求更新",
"useApiKeyTip": "提示:無法正常登入?請使用 Comfy API 金鑰選項。",
"userNotAuthenticated": "使用者未驗證"

View File

@@ -1642,9 +1642,18 @@
"exportRecording": "导出录制",
"exportingModel": "正在导出模型...",
"fov": "视场",
"hdri": {
"changeFile": "更换HDRI",
"intensity": "强度",
"label": "HDRI环境",
"removeFile": "移除HDRI",
"showAsBackground": "作为背景显示",
"uploadFile": "上传HDRI.hdr, .exr"
},
"light": "灯光",
"lightIntensity": "光照强度",
"loadingBackgroundImage": "正在加载背景图像",
"loadingHDRI": "正在加载HDRI...",
"loadingModel": "正在加载3D模型...",
"materialMode": "材质模式",
"materialModes": {
@@ -3443,6 +3452,7 @@
"failedToInitiateCreditPurchase": "发起积分购买失败:{error}",
"failedToInitiateSubscription": "订阅启动失败:{error}",
"failedToLoadBackgroundImage": "无法加载背景图片",
"failedToLoadHDRI": "HDRI文件加载失败",
"failedToLoadModel": "无法加载3D模型",
"failedToPurchaseCredits": "购买积分失败:{error}",
"failedToQueue": "排队失败",
@@ -3478,6 +3488,7 @@
"pleaseSelectOutputNodes": "请选择输出节点",
"unableToGetModelFilePath": "无法获取模型文件路径",
"unauthorizedDomain": "您的域名 {domain} 未被授权使用此服务。请联系 {email} 将您的域名添加到白名单。",
"unsupportedHDRIFormat": "不支持的文件格式。请上传.hdr或.exr文件。",
"updateRequested": "已请求更新",
"useApiKeyTip": "提示:无法正常登录?请使用 Comfy API Key 选项。",
"userNotAuthenticated": "用户未认证"

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
@@ -17,8 +17,8 @@ const i18n = createI18n({
}
})
function mountRoleBadge(role: 'owner' | 'member') {
return mount(RoleBadge, {
function renderRoleBadge(role: 'owner' | 'member') {
return render(RoleBadge, {
props: { role },
global: { plugins: [i18n] }
})
@@ -26,12 +26,12 @@ function mountRoleBadge(role: 'owner' | 'member') {
describe('RoleBadge', () => {
it('renders the owner label', () => {
const wrapper = mountRoleBadge('owner')
expect(wrapper.text()).toBe('Owner')
renderRoleBadge('owner')
expect(screen.getByText('Owner')).toBeInTheDocument()
})
it('renders the member label', () => {
const wrapper = mountRoleBadge('member')
expect(wrapper.text()).toBe('Member')
renderRoleBadge('member')
expect(screen.getByText('Member')).toBeInTheDocument()
})
})

View File

@@ -1,13 +1,19 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import LinearWelcome from './LinearWelcome.vue'
const hasNodes = ref(false)
const hasOutputs = ref(false)
const enterBuilder = vi.fn()
const { hasNodes, hasOutputs, enterBuilder } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
hasNodes: ref(false),
hasOutputs: ref(false),
enterBuilder: vi.fn()
}
})
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: vi.fn() })
@@ -33,12 +39,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function mountComponent(
function renderComponent(
opts: { hasNodes?: boolean; hasOutputs?: boolean } = {}
) {
hasNodes.value = opts.hasNodes ?? false
hasOutputs.value = opts.hasOutputs ?? false
return mount(LinearWelcome, {
return render(LinearWelcome, {
global: { plugins: [i18n] }
})
}
@@ -51,30 +57,27 @@ describe('LinearWelcome', () => {
})
it('shows empty workflow text when there are no nodes', () => {
const wrapper = mountComponent({ hasNodes: false })
renderComponent({ hasNodes: false })
expect(
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(true)
screen.getByTestId('linear-welcome-empty-workflow')
).toBeInTheDocument()
expect(
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(false)
screen.queryByTestId('linear-welcome-build-app')
).not.toBeInTheDocument()
})
it('shows build app button when there are nodes but no outputs', () => {
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
renderComponent({ hasNodes: true, hasOutputs: false })
expect(
wrapper.find('[data-testid="linear-welcome-empty-workflow"]').exists()
).toBe(false)
expect(
wrapper.find('[data-testid="linear-welcome-build-app"]').exists()
).toBe(true)
screen.queryByTestId('linear-welcome-empty-workflow')
).not.toBeInTheDocument()
expect(screen.getByTestId('linear-welcome-build-app')).toBeInTheDocument()
})
it('clicking build app button calls enterBuilder', async () => {
const wrapper = mountComponent({ hasNodes: true, hasOutputs: false })
await wrapper
.find('[data-testid="linear-welcome-build-app"]')
.trigger('click')
const user = userEvent.setup()
renderComponent({ hasNodes: true, hasOutputs: false })
await user.click(screen.getByTestId('linear-welcome-build-app'))
expect(enterBuilder).toHaveBeenCalled()
})
})

View File

@@ -1,9 +1,7 @@
/* eslint-disable testing-library/no-container */
/* eslint-disable testing-library/no-node-access */
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +11,6 @@ import type {
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -98,35 +95,6 @@ describe('NodeWidgets', () => {
})
}
function mountComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return mount(NodeWidgets, {
props: { nodeData },
global: {
plugins: [pinia],
stubs: { InputSlot: true },
mocks: { $t: (key: string) => key }
}
})
}
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
fromAny<{ processedWidgets: unknown[] }, unknown>(
wrapper.vm
).processedWidgets.map(
(entry) =>
(
entry as {
simplified: {
borderStyle?: string
}
}
).simplified.borderStyle
)
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
@@ -155,19 +123,6 @@ describe('NodeWidgets', () => {
expect(stub).not.toBeNull()
expect(stub!.getAttribute('data-node-type')).toBe('')
})
it.for(['CheckpointLoaderSimple', 'LoraLoader', 'VAELoader', 'KSampler'])(
'passes correct node type: %s',
(nodeType) => {
const widget = createMockWidget()
const nodeData = createMockNodeData(nodeType, [widget])
const { container } = renderComponent(nodeData)
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
expect(stub!.getAttribute('data-node-type')).toBe(nodeType)
}
)
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
@@ -318,54 +273,6 @@ describe('NodeWidgets', () => {
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '4')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(
false
)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({

View File

@@ -80,56 +80,16 @@
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import { onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
// Import widget components directly
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { cn } from '@/utils/tailwindUtil'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
import InputSlot from './InputSlot.vue'
@@ -141,12 +101,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { isSelectInputsMode } = useAppMode()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -160,8 +115,6 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -173,314 +126,11 @@ onErrorCaptured((error) => {
return false
})
const canSelectInputs = computed(
() =>
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()
const showAdvanced = computed(
() =>
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: string,
widgetOptions: IWidgetOptions | Record<string, never>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
widget.slotName ?? widget.name,
widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
)
}
}
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: string,
nodeErrors: { errors: { extra_info?: { input_name?: string } }[] } | undefined
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
const inputName = widget.slotName ?? widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.name
)
)
}
function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: string | number | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
return {
dedupeIdentity,
renderKey
}
}
function isWidgetVisible(options: IWidgetOptions): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced.value)
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
// nodeData.id is the local node ID; subgraph nodes need the full execution
// path (e.g. "65:63") to match keys in lastNodeErrors.
const nodeExecId = app.isGraphReady
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
: String(nodeData.id ?? '')
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with disabled override for
// slot-linked widgets or widgets with disabled state (e.g. display-only)
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = widget.nodeId
? widget.nodeId
: nodeData
? getLocatorIdFromNodeData(nodeData)
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions
)
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
updateHandler,
tooltipConfig,
slotMetadata
})
}
return result
})
const gridTemplateRows = computed((): string => {
// Use processedWidgets directly since it already has store-based hidden/advanced
return toValue(processedWidgets)
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
})
const {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
</script>

View File

@@ -0,0 +1,499 @@
import type { TooltipOptions } from 'primevue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import {
computeProcessedWidgets,
getWidgetIdentity,
hasWidgetError,
isWidgetVisible
} from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: {
id: 'graph-test'
}
}
}
})
}))
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
describe('getWidgetIdentity', () => {
it('returns stable dedupeIdentity for widgets with storeNodeId', () => {
const widget = createMockWidget({
storeNodeId: 'subgraph:19',
storeName: 'text',
slotName: 'text',
type: 'text'
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '1', 0)
expect(dedupeIdentity).toBe('node:19:text:text:text')
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey for widgets without stable identity', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: '65:18'
})
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
})
})
describe('isWidgetVisible', () => {
it('returns true for normal widgets', () => {
expect(isWidgetVisible({}, false)).toBe(true)
})
it('returns false for hidden widgets', () => {
expect(isWidgetVisible({ hidden: true }, false)).toBe(false)
})
it('returns false for advanced widgets when showAdvanced is false', () => {
expect(isWidgetVisible({ advanced: true }, false)).toBe(false)
})
it('returns true for advanced widgets when showAdvanced is true', () => {
expect(isWidgetVisible({ advanced: true }, true)).toBe(true)
})
})
describe('hasWidgetError', () => {
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingModelStore: ReturnType<typeof useMissingModelStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
executionErrorStore = useExecutionErrorStore()
missingModelStore = useMissingModelStore()
})
it('returns false when no errors', () => {
const widget = createMockWidget()
expect(
hasWidgetError(
widget,
'1',
undefined,
executionErrorStore,
missingModelStore
)
).toBe(false)
})
it('returns true when node has matching input error', () => {
const widget = createMockWidget({ name: 'seed' })
const nodeErrors = {
errors: [{ extra_info: { input_name: 'seed' } }]
}
expect(
hasWidgetError(
widget,
'1',
nodeErrors,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('returns true via sourceExecutionId when execution store has matching error', () => {
const widget = createMockWidget({
name: 'seed',
sourceExecutionId: '65:18'
})
executionErrorStore.lastNodeErrors = {
'65:18': {
errors: [
{
type: 'required_input_missing',
message: 'seed is required',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
expect(
hasWidgetError(
widget,
'1',
undefined,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('returns true when widget has missing model', () => {
const widget = createMockWidget({ name: 'ckpt_name' })
vi.spyOn(missingModelStore, 'isWidgetMissingModel').mockReturnValue(true)
expect(
hasWidgetError(
widget,
'1',
undefined,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('uses slotName for error matching when present', () => {
const widget = createMockWidget({
name: 'internal_name',
slotName: 'display_slot'
})
const nodeErrors = {
errors: [{ extra_info: { input_name: 'display_slot' } }]
}
expect(
hasWidgetError(
widget,
'1',
nodeErrors,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
})
const noopUi = {
getTooltipConfig: () => ({}) as TooltipOptions,
handleNodeRightClick: () => {}
}
describe('computeProcessedWidgets borderStyle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies promoted border styling to intermediate promoted widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '3',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '4',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(false)
})
it('applies advanced border styling to advanced widgets', () => {
const advancedWidget = createMockWidget({
name: 'text',
type: 'combo',
options: { advanced: true }
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'TestNode',
widgets: [advancedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: true,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0].simplified.borderStyle).toBe(
'ring ring-component-node-widget-advanced'
)
})
it('deduplication keeps visible widget over hidden duplicate', () => {
const hiddenWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
slotName: 'text',
options: { hidden: true }
})
const visibleWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
slotName: 'text'
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'TestNode',
widgets: [hiddenWidget, visibleWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].hidden).toBe(false)
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const GRAPH_ID = 'graph-test'
const NODE_ID = '1'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function processWidgets(widgets: SafeWidgetData[]) {
return computeProcessedWidgets({
nodeData: {
id: NODE_ID,
type: 'TestNode',
widgets,
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
}
it('calls widget.callback with the new value when widgetState exists', () => {
const callback = vi.fn()
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
callback
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
processed.updateHandler(42)
expect(callback).toHaveBeenCalledWith(42)
})
it('calls widget.callback even when widgetState is undefined (no store entry)', () => {
const callback = vi.fn()
const widget = createMockWidget({
name: 'unregistered_widget',
nodeId: NODE_ID,
callback
})
const [processed] = processWidgets([widget])
processed.updateHandler('new-value')
expect(callback).toHaveBeenCalledWith('new-value')
})
it('updates widgetState.value when store entry exists', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
processed.updateHandler(99)
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
expect(state?.value).toBe(99)
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
executionErrorStore.lastNodeErrors = {
[NODE_ID]: {
errors: [
{
type: 'required_input_missing',
message: 'seed is required',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const [processed] = processWidgets([widget])
expect(
hasWidgetError(
widget,
NODE_ID,
executionErrorStore.lastNodeErrors[NODE_ID],
executionErrorStore,
missingModelStore
)
).toBe(true)
processed.updateHandler('fixed-value')
expect(
hasWidgetError(
widget,
NODE_ID,
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,
missingModelStore
)
).toBe(false)
})
})

View File

@@ -0,0 +1,431 @@
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
interface WidgetUiCallbacks {
getTooltipConfig: (widget: SafeWidgetData) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: string) => void
}
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
rootGraph: LGraph | null
ui: WidgetUiCallbacks
}
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: string,
widgetOptions: IWidgetOptions | Record<string, never>,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
widget.slotName ?? widget.name,
widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
)
}
}
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: string,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
const inputName = widget.slotName ?? widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.name
)
)
}
export function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: string | number | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
return {
dedupeIdentity,
renderKey
}
}
export function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean
): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced)
}
export function computeProcessedWidgets({
nodeData,
graphId,
showAdvanced,
isGraphReady,
rootGraph,
ui
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
const nodeExecId =
isGraphReady && rootGraph
? getExecutionIdFromNodeData(rootGraph, nodeData)
: String(nodeData.id ?? '')
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions, showAdvanced)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = widget.nodeId
? widget.nodeId
: nodeData
? getLocatorIdFromNodeData(nodeData)
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions,
executionErrorStore
)
const tooltipConfig = ui.getTooltipConfig(widget)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(
widget,
nodeExecId,
nodeErrors,
executionErrorStore,
missingModelStore
),
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
updateHandler,
tooltipConfig,
slotMetadata
})
}
return result
}
export function useProcessedWidgets(
nodeDataGetter: () => VueNodeData | undefined
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const { isSelectInputsMode } = useAppMode()
const { handleNodeRightClick } = useNodeEventHandlers()
const nodeType = computed(() => nodeDataGetter()?.type || '')
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(nodeType)
const ui: WidgetUiCallbacks = {
getTooltipConfig: (widget) => createTooltipConfig(getWidgetTooltip(widget)),
handleNodeRightClick
}
const showAdvanced = computed(
() =>
nodeDataGetter()?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const canSelectInputs = computed(() => {
const nodeData = nodeDataGetter()
return (
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
})
const processedWidgets = computed((): ProcessedWidget[] =>
computeProcessedWidgets({
nodeData: nodeDataGetter(),
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,
rootGraph: app.isGraphReady ? app.rootGraph : null,
ui
})
)
const visibleWidgets = computed(() =>
processedWidgets.value.filter((w) =>
isWidgetVisible(
{ hidden: w.hidden, advanced: w.advanced },
showAdvanced.value
)
)
)
const gridTemplateRows = computed((): string =>
visibleWidgets.value
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
)
return {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced,
visibleWidgets
}
}

View File

@@ -1,17 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { computed } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { computed, nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
@@ -55,7 +52,7 @@ vi.mock(
})
)
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
const { mockMediaAssets } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
@@ -68,8 +65,7 @@ const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
},
mockResolveOutputAssetItems: vi.fn()
}
}
})
@@ -78,732 +74,187 @@ vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
resolveOutputAssetItems: vi.fn().mockResolvedValue([])
}))
const mockUpdateSelectedItems = vi.hoisted(() => vi.fn())
const mockHandleFilesUpdate = vi.hoisted(() => vi.fn())
const { mockItemsRef, mockSelectedSetRef, mockFilterSelectedRef } = vi.hoisted(
() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
mockItemsRef: ref([]) as Ref<FormDropdownItem[]>,
mockSelectedSetRef: ref(new Set()) as Ref<Set<string>>,
mockFilterSelectedRef: ref('all') as Ref<string>
}
}
)
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems',
() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { computed } = require('vue')
return {
useWidgetSelectItems: () => ({
dropdownItems: computed(() => mockItemsRef.value),
displayItems: computed(() => mockItemsRef.value),
filterSelected: mockFilterSelectedRef,
filterOptions: computed(() => [
{ name: 'All', value: 'all' },
{ name: 'Inputs', value: 'inputs' }
]),
ownershipSelected: ref('all'),
showOwnershipFilter: computed(() => false),
ownershipOptions: computed(() => []),
baseModelSelected: ref(new Set<string>()),
showBaseModelFilter: computed(() => false),
baseModelOptions: computed(() => []),
selectedSet: computed(() => mockSelectedSetRef.value)
})
}
}
)
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions',
() => ({
useWidgetSelectActions: () => ({
updateSelectedItems: mockUpdateSelectedItems,
handleFilesUpdate: mockHandleFilesUpdate
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: FormDropdownItem[]
outputItems: FormDropdownItem[]
dropdownItems: FormDropdownItem[]
filterSelected: string
updateSelectedItems: (selectedSet: Set<string>) => void
}
describe('WidgetSelectDropdown custom label mapping', () => {
const createSelectDropdownWidget = (
value: string = 'img_001.png',
options: {
values?: string[]
getOptionLabel?: (value?: string | null) => string
} = {},
spec?: ComboInputSpec
) =>
createMockWidget<string | undefined>({
value,
name: 'test_image_select',
type: 'combo',
options: {
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
...options
},
spec
})
const mountComponent = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined,
assetKind: 'image' | 'video' | 'audio' = 'image'
): VueWrapper<WidgetSelectDropdownInstance> => {
return fromAny<VueWrapper<WidgetSelectDropdownInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind,
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
)
}
describe('when custom labels are not provided', () => {
it('uses values as labels when no mapping provided', () => {
const widget = createSelectDropdownWidget('img_001.png')
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('when custom labels are provided via getOptionLabel', () => {
it('displays custom labels while preserving original values', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
const mapping: Record<string, string> = {
'img_001.png': 'Vacation Photo',
'photo_abc.jpg': 'Family Portrait',
'hash789.png': 'Sunset Beach'
}
return mapping[value] || value
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Vacation Photo')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Family Portrait')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Sunset Beach')
expect(getOptionLabel).toHaveBeenCalledWith('img_001.png')
expect(getOptionLabel).toHaveBeenCalledWith('photo_abc.jpg')
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
})
it('emits original values when items with custom labels are selected', async () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
return `Custom: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
// Simulate selecting an item
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
wrapper.vm.updateSelectedItems(selectedSet)
// Should emit the original value, not the custom label
expect(wrapper.emitted('update:modelValue')).toBeDefined()
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
'photo_abc.jpg'
])
})
it('falls back to original value when label mapping fails', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'photo_abc.jpg') {
throw new Error('Mapping failed')
}
return `Labeled: ${value}`
})
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('falls back to original value when label mapping returns empty string', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'photo_abc.jpg') {
return ''
}
return `Labeled: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
})
it('falls back to original value when label mapping returns undefined', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'hash789.png') {
return fromAny<string, unknown>(undefined)
}
return `Labeled: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('output items with custom label mapping', () => {
it('applies custom label mapping to output items from queue history', () => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
return `Output: ${value}`
})
const widget = createSelectDropdownWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const outputItems = wrapper.vm.outputItems
expect(outputItems).toBeDefined()
expect(Array.isArray(outputItems)).toBe(true)
})
})
describe('missing value handling for template-loaded nodes', () => {
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
const widget = createSelectDropdownWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(2)
expect(
inputItems.some((item) => item.name === 'template_image.png')
).toBe(false)
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
const dropdownItems = wrapper.vm.dropdownItems
expect(
dropdownItems.some((item) => item.name === 'template_image.png')
).toBe(true)
expect(dropdownItems[0].name).toBe('template_image.png')
expect(dropdownItems[0].id).toBe('missing-template_image.png')
})
it('does not include fallback item when filter is "inputs"', async () => {
const widget = createSelectDropdownWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
wrapper.vm.filterSelected = 'inputs'
await wrapper.vm.$nextTick()
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not include fallback item when filter is "outputs"', async () => {
const widget = createSelectDropdownWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
wrapper.vm.filterSelected = 'outputs'
await wrapper.vm.$nextTick()
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue exists in available items', () => {
const widget = createSelectDropdownWidget('img_001.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'img_001.png')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue is undefined', () => {
const widget = createSelectDropdownWidget(
fromAny<string, unknown>(undefined),
{
values: ['img_001.png', 'photo_abc.jpg']
}
)
const wrapper = mountComponent(widget, undefined)
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
})
})
describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
interface CloudModeInstance extends ComponentPublicInstance {
dropdownItems: FormDropdownItem[]
displayItems: FormDropdownItem[]
selectedSet: Set<string>
}
const createTestAsset = (
id: string,
name: string,
preview_url: string
): AssetItem => ({
id,
name,
preview_url,
tags: []
})
const createCloudModeWidget = (
value: string = 'model.safetensors'
): SimplifiedWidget<string | undefined> => ({
name: 'test_model_select',
type: 'combo',
value,
options: {
values: [],
nodeType: 'CheckpointLoaderSimple'
}
})
const mountCloudComponent = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<CloudModeInstance> => {
return fromAny<VueWrapper<CloudModeInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
)
}
beforeEach(() => {
mockAssetsData.items = []
})
it('does not include missing items in cloud asset mode dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(1)
expect(dropdownItems[0].name).toBe('existing_model.safetensors')
expect(
dropdownItems.some((item) => item.name === 'missing_model.safetensors')
).toBe(false)
})
it('shows only available cloud assets in dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'model_a.safetensors',
'https://example.com/a.jpg'
),
createTestAsset(
'asset-2',
'model_b.safetensors',
'https://example.com/b.jpg'
)
]
const widget = createCloudModeWidget('model_a.safetensors')
const wrapper = mountCloudComponent(widget, 'model_a.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(dropdownItems.map((item) => item.name)).toEqual([
'model_a.safetensors',
'model_b.safetensors'
])
})
it('returns empty dropdown when no cloud assets available', () => {
mockAssetsData.items = []
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const dropdownItems = wrapper.vm.dropdownItems
expect(dropdownItems).toHaveLength(0)
})
it('includes missing cloud asset in displayItems for input field visibility', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const widget = createCloudModeWidget('missing_model.safetensors')
const wrapper = mountCloudComponent(widget, 'missing_model.safetensors')
const displayItems = wrapper.vm.displayItems
expect(displayItems).toHaveLength(2)
expect(displayItems[0].name).toBe('missing_model.safetensors')
expect(displayItems[0].id).toBe('missing-missing_model.safetensors')
expect(displayItems[1].name).toBe('existing_model.safetensors')
const selectedSet = wrapper.vm.selectedSet
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
})
})
describe('WidgetSelectDropdown multi-output jobs', () => {
interface MultiOutputInstance extends ComponentPublicInstance {
outputItems: FormDropdownItem[]
}
function makeMultiOutputAsset(
jobId: string,
name: string,
nodeId: string,
outputCount: number
) {
return {
id: jobId,
name,
preview_url: `/api/view?filename=${name}&type=output`,
tags: ['output'],
user_metadata: {
jobId,
nodeId,
subfolder: '',
outputCount,
allOutputs: [
{
filename: name,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
]
}
}
}
function mountMultiOutput(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<MultiOutputInstance> {
return fromAny<VueWrapper<MultiOutputInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: { widget, modelValue, assetKind: 'image' as const },
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
})
)
}
const defaultWidget = () =>
createMockWidget<string | undefined>({
value: 'output_001.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
describe('WidgetSelectDropdown', () => {
beforeEach(() => {
mockMediaAssets.media.value = []
mockResolveOutputAssetItems.mockReset()
})
it('shows all outputs after resolving multi-output jobs', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'job-1-5-output_001.png',
name: 'output_001.png',
preview_url: '/api/view?filename=output_001.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_002.png',
name: 'output_002.png',
preview_url: '/api/view?filename=output_002.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_003.png',
name: 'output_003.png',
preview_url: '/api/view?filename=output_003.png&type=output',
tags: ['output']
}
])
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(3)
})
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
'output_001.png [output]',
'output_002.png [output]',
'output_003.png [output]'
])
})
it('shows preview output when job has only one output', () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
]
const widget = createMockWidget<string | undefined>({
value: 'single.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
const wrapper = mountMultiOutput(widget, 'single.png')
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
})
it('resolves two multi-output jobs independently', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
]
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
if (meta.jobId === 'job-A') {
return [
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
]
}
return [
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
]
})
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(4)
})
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toContain('a1.png [output]')
expect(names).toContain('a2.png [output]')
expect(names).toContain('b1.png [output]')
expect(names).toContain('b2.png [output]')
})
it('resolves outputs when allOutputs already contains all items', async () => {
mockMediaAssets.media.value = [
{
id: 'job-complete',
name: 'preview.png',
preview_url: '/api/view?filename=preview.png&type=output',
tags: ['output'],
user_metadata: {
jobId: 'job-complete',
nodeId: '1',
subfolder: '',
outputCount: 2,
allOutputs: [
{
filename: 'out1.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
{
filename: 'out2.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
]
}
}
]
mockResolveOutputAssetItems.mockResolvedValue([
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
])
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(2)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-complete' }),
expect.any(Object)
)
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
})
it('falls back to preview when resolver rejects', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
]
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to resolve multi-output job',
'job-fail',
expect.any(Error)
)
})
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
consoleWarnSpy.mockRestore()
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void
handleFilesUpdate: (files: File[]) => Promise<void>
}
const mountForUndo = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<UndoTrackingInstance> => {
return fromAny<VueWrapper<UndoTrackingInstance>, unknown>(
mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
)
}
beforeEach(() => {
mockCheckState.mockClear()
mockAssetsData.items = []
mockItemsRef.value = []
mockSelectedSetRef.value = new Set()
mockFilterSelectedRef.value = 'all'
mockUpdateSelectedItems.mockClear()
mockHandleFilesUpdate.mockClear()
})
it('calls checkState after dropdown selection changes modelValue', () => {
function renderComponent(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined,
extraProps: Record<string, unknown> = {}
) {
return render(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input',
...extraProps
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
}
it('renders the dropdown component', () => {
mockItemsRef.value = [
{ id: 'input-0', name: 'img_001.png' },
{ id: 'input-1', name: 'photo_abc.jpg' }
]
mockSelectedSetRef.value = new Set(['input-0'])
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png', 'photo_abc.jpg'] }
options: {
values: ['img_001.png', 'photo_abc.jpg']
}
})
const wrapper = mountForUndo(widget, 'img_001.png')
wrapper.vm.updateSelectedItems(new Set(['input-1']))
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
expect(mockCheckState).toHaveBeenCalledOnce()
renderComponent(widget, 'img_001.png')
expect(screen.getByText('img_001.png')).toBeDefined()
})
it('calls checkState after file upload completes', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
} as Response)
it('renders in cloud asset mode', () => {
mockAssetsData.items = [
{
id: 'asset-1',
name: 'model_a.safetensors',
preview_url: 'https://example.com/a.jpg',
tags: []
}
]
mockItemsRef.value = [{ id: 'asset-1', name: 'model_a.safetensors' }]
mockSelectedSetRef.value = new Set(['asset-1'])
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
value: 'model_a.safetensors',
name: 'test_model',
type: 'combo',
options: { values: ['img_001.png'] }
options: {
values: [],
nodeType: 'CheckpointLoaderSimple'
}
})
const wrapper = mountForUndo(widget, 'img_001.png')
renderComponent(widget, 'model_a.safetensors', {
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
})
expect(screen.getByText('model_a.safetensors')).toBeDefined()
})
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
describe('composable wiring', () => {
const items: FormDropdownItem[] = [
{ id: 'input-0', name: 'cat.png', label: 'cat.png' },
{ id: 'input-1', name: 'dog.png', label: 'dog.png' }
]
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
function renderDefault() {
mockItemsRef.value = items
const widget = createMockWidget<string | undefined>({
value: 'cat.png',
name: 'test_image',
type: 'combo',
options: { values: ['cat.png', 'dog.png'] }
})
return renderComponent(widget, 'cat.png')
}
it('displays the item whose id is in selectedSet', async () => {
mockSelectedSetRef.value = new Set(['input-1'])
renderDefault()
expect(screen.getByText('dog.png')).toBeDefined()
expect(screen.queryByText('cat.png')).toBeNull()
})
it('shows placeholder when selectedSet is empty', () => {
mockSelectedSetRef.value = new Set()
renderDefault()
expect(screen.queryByText('cat.png')).toBeNull()
expect(screen.queryByText('dog.png')).toBeNull()
})
it('updates displayed selection when selectedSet changes', async () => {
mockSelectedSetRef.value = new Set(['input-0'])
renderDefault()
expect(screen.getByText('cat.png')).toBeDefined()
mockSelectedSetRef.value = new Set(['input-1'])
await nextTick()
expect(screen.getByText('dog.png')).toBeDefined()
expect(screen.queryByText('cat.png')).toBeNull()
})
})
})

View File

@@ -1,45 +1,20 @@
<script setup lang="ts">
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
import { computed, provide, ref, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import {
filterItemByBaseModels,
filterItemByOwnership
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import type {
FilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type {
FormDropdownItem,
LayoutMode
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
@@ -71,7 +46,6 @@ const modelValue = defineModel<string | undefined>({
})
const { t } = useI18n()
const toastStore = useToastStore()
const outputMediaAssets = useMediaAssets('output')
@@ -92,261 +66,34 @@ const getAssetData = () => {
}
const assetData = getAssetData()
const filterSelected = ref('all')
const filterOptions = computed<FilterOption[]>(() => {
if (props.isAssetMode) {
const categoryName = assetData?.category.value ?? 'All'
return [{ name: capitalize(categoryName), value: 'all' }]
}
return [
{ name: 'All', value: 'all' },
{ name: 'Inputs', value: 'inputs' },
{ name: 'Outputs', value: 'outputs' }
]
const {
dropdownItems,
displayItems,
filterSelected,
filterOptions,
ownershipSelected,
showOwnershipFilter,
ownershipOptions,
baseModelSelected,
showBaseModelFilter,
baseModelOptions,
selectedSet
} = useWidgetSelectItems({
values: () => props.widget.options?.values as unknown[] | undefined,
getOptionLabel: () => props.widget.options?.getOptionLabel,
modelValue,
assetKind: () => props.assetKind,
outputMediaAssets,
assetData,
isAssetMode: () => props.isAssetMode
})
const ownershipSelected = ref<OwnershipOption>('all')
const showOwnershipFilter = computed(() => props.isAssetMode)
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
() => assetData?.assets.value ?? []
)
const baseModelSelected = ref<Set<string>>(new Set())
const showBaseModelFilter = computed(() => props.isAssetMode)
const baseModelOptions = computed<FilterOption[]>(() => {
if (!props.isAssetMode || !assetData) return []
return availableBaseModels.value
})
const selectedSet = ref<Set<string>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
* Falls back to the original value if getOptionLabel is not provided,
* returns undefined/null, or throws an error.
*/
function getDisplayLabel(value: string): string {
const getOptionLabel = props.widget.options?.getOptionLabel
if (!getOptionLabel) return value
try {
return getOptionLabel(value) || value
} catch (e) {
console.error('Failed to map value:', e)
return value
}
}
const inputItems = computed<FormDropdownItem[]>(() => {
const values = props.widget.options?.values || []
if (!Array.isArray(values)) {
return []
}
return values.map((value, index) => ({
id: `input-${index}`,
preview_url: getMediaUrl(String(value), 'input'),
name: String(value),
label: getDisplayLabel(String(value))
}))
})
function assetKindToMediaType(kind: AssetKind): string {
return kind === 'mesh' ? '3D' : kind
}
/**
* Per-job cache of resolved outputs for multi-output jobs.
* Keyed by jobId, populated lazily via resolveOutputAssetItems which
* fetches full outputs through getJobDetail (itself LRU-cached).
*/
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
const pendingJobIds = new Set<string>()
watch(
() => outputMediaAssets.media.value,
(assets, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
pendingJobIds.clear()
for (const asset of assets) {
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
if (
outputCount <= 1 ||
resolvedByJobId.value.has(meta.jobId) ||
pendingJobIds.has(meta.jobId)
)
continue
pendingJobIds.add(meta.jobId)
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
.then((resolved) => {
if (cancelled || !resolved.length) return
const next = new Map(resolvedByJobId.value)
next.set(meta.jobId, resolved)
resolvedByJobId.value = next
})
.catch((error) => {
console.warn('Failed to resolve multi-output job', meta.jobId, error)
})
.finally(() => {
pendingJobIds.delete(meta.jobId)
})
}
},
{ immediate: true }
)
const outputItems = computed<FormDropdownItem[]>(() => {
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
return []
const targetMediaType = assetKindToMediaType(props.assetKind!)
const seen = new Set<string>()
const items: FormDropdownItem[] = []
const assets = outputMediaAssets.media.value.flatMap((asset) => {
const meta = getOutputAssetMetadata(asset.user_metadata)
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
return resolved ?? [asset]
})
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
seen.add(asset.id)
const annotatedPath = `${asset.name} [output]`
items.push({
id: `output-${annotatedPath}`,
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
name: annotatedPath,
label: getDisplayLabel(annotatedPath)
})
}
return items
})
/**
* Creates a fallback item for the current modelValue when it doesn't exist
* in the available items list. This handles cases like template-loaded nodes
* where the saved value may not exist in the current server environment.
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
*/
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
// Check in cloud mode assets
if (props.isAssetMode && assetData) {
const existsInAssets = assetData.assets.value.some(
(asset) => getAssetFilename(asset) === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
preview_url: '',
name: currentValue,
label: getDisplayLabel(currentValue)
}
}
// Check in local mode inputs/outputs
const existsInInputs = inputItems.value.some(
(item) => item.name === currentValue
)
const existsInOutputs = outputItems.value.some(
(item) => item.name === currentValue
)
if (existsInInputs || existsInOutputs) return undefined
const isOutput = currentValue.endsWith(' [output]')
const strippedValue = isOutput
? currentValue.replace(' [output]', '')
: currentValue
return {
id: `missing-${currentValue}`,
preview_url: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
name: currentValue,
label: getDisplayLabel(currentValue)
}
})
/**
* Transforms AssetItem[] to FormDropdownItem[] for cloud mode.
* Uses getAssetFilename for display name, asset.name for label.
*/
const assetItems = computed<FormDropdownItem[]>(() => {
if (!props.isAssetMode || !assetData) return []
return assetData.assets.value.map((asset) => ({
id: asset.id,
name: getAssetFilename(asset),
label: getAssetDisplayName(asset),
preview_url: asset.preview_url,
is_immutable: asset.is_immutable,
base_models: getAssetBaseModels(asset)
}))
})
const ownershipFilteredAssetItems = computed<FormDropdownItem[]>(() =>
filterItemByOwnership(assetItems.value, ownershipSelected.value)
)
const baseModelFilteredAssetItems = computed<FormDropdownItem[]>(() =>
filterItemByBaseModels(
ownershipFilteredAssetItems.value,
baseModelSelected.value
)
)
const allItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
// Cloud assets not in user's library shouldn't appear as search results (COM-14333).
// Unlike local mode, cloud users can't access files they don't own.
return baseModelFilteredAssetItems.value
}
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
]
})
const dropdownItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
}
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
case 'outputs':
return outputItems.value
case 'all':
default:
return allItems.value
}
})
/**
* Items used for display in the input field. In cloud mode, includes
* missing items so users can see their selected value even if not in library.
*/
const displayItems = computed<FormDropdownItem[]>(() => {
if (props.isAssetMode && assetData && missingValueItem.value) {
return [missingValueItem.value, ...baseModelFilteredAssetItems.value]
}
return dropdownItems.value
const { updateSelectedItems, handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems,
widget: () => props.widget,
uploadFolder: () => props.uploadFolder,
uploadSubfolder: () => props.uploadSubfolder
})
const mediaPlaceholder = computed(() => {
@@ -392,141 +139,12 @@ const acceptTypes = computed(() => {
case 'mesh':
return SUPPORTED_EXTENSIONS_ACCEPT
default:
return undefined // model or unknown
return undefined
}
})
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
[modelValue, displayItems],
([currentValue]) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = displayItems.value.find((item) => item.name === currentValue)
if (!item) {
selectedSet.value.clear()
return
}
selectedSet.value.clear()
selectedSet.value.add(item.id)
},
{ immediate: true }
)
function updateSelectedItems(selectedItems: Set<string>) {
let id: string | undefined = undefined
if (selectedItems.size > 0) {
id = selectedItems.values().next().value!
}
if (id == null) {
modelValue.value = undefined
return
}
const name = dropdownItems.value.find((item) => item.id === id)?.name
if (!name) {
modelValue.value = undefined
return
}
modelValue.value = name
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
const uploadFile = async (
file: File,
isPasted: boolean = false,
formFields: Partial<{ type: ResultItemType }> = {}
) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
else if (props.uploadSubfolder)
body.append('subfolder', props.uploadSubfolder)
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
return null
}
const data = await resp.json()
// Update AssetsStore when uploading to input folder
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const uploadFiles = async (files: File[]): Promise<string[]> => {
const folder = props.uploadFolder ?? 'input'
const uploadPromises = files.map((file) =>
uploadFile(file, false, { type: folder })
)
const results = await Promise.all(uploadPromises)
return results.filter((path): path is string => path !== null)
}
async function handleFilesUpdate(files: File[]) {
if (!files || files.length === 0) return
try {
// 1. Upload files to server
const uploadedPaths = await uploadFiles(files)
if (uploadedPaths.length === 0) {
toastStore.addAlert('File upload failed')
return
}
// 2. Update widget options to include new files
// This simulates what addToComboValues does but for SimplifiedWidget
const values = props.widget.options?.values
if (Array.isArray(values)) {
uploadedPaths.forEach((path) => {
if (!values.includes(path)) {
values.push(path)
}
})
}
// 3. Update widget value to the first uploaded file
modelValue.value = uploadedPaths[0]
// 4. Trigger callback to notify underlying LiteGraph widget
if (props.widget.callback) {
props.widget.callback(uploadedPaths[0])
}
// 5. Snapshot undo state so the image change gets its own undo entry
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch (error) {
console.error('Upload error:', error)
toastStore.addAlert(`Upload failed: ${error}`)
}
}
function getMediaUrl(
filename: string,
type: 'input' | 'output' = 'input'
): string {
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
return ''
const params = new URLSearchParams({ filename, type })
appendCloudResParam(params, filename)
return `/api/view?${params}`
}
function handleIsOpenUpdate(isOpen: boolean) {
if (isOpen && !outputMediaAssets.loading.value) {
void outputMediaAssets.refresh()
@@ -537,11 +155,11 @@ function handleIsOpenUpdate(isOpen: boolean) {
<template>
<WidgetLayoutField :widget>
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:selected="selectedSet"
:items="dropdownItems"
:display-items="displayItems"
:placeholder="mediaPlaceholder"

View File

@@ -0,0 +1,229 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const mockCheckState = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
'@/platform/workflow/management/stores/workflowStore'
)
return {
...actual,
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
checkState: mockCheckState
}
}
})
}
})
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((url: string) => url),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
function createItems(...names: string[]): FormDropdownItem[] {
return names.map((name, i) => ({
id: `input-${i}`,
name,
label: name,
preview_url: ''
}))
}
describe('useWidgetSelectActions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockCheckState.mockClear()
})
describe('updateSelectedItems', () => {
it('sets modelValue to the selected item name', () => {
const modelValue = ref<string | undefined>('img_001.png')
const items = createItems('img_001.png', 'photo_abc.jpg')
const { updateSelectedItems } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => items),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
value: 'img_001.png'
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
updateSelectedItems(new Set(['input-1']))
expect(modelValue.value).toBe('photo_abc.jpg')
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('clears modelValue when empty set', () => {
const modelValue = ref<string | undefined>('img_001.png')
const items = createItems('img_001.png')
const { updateSelectedItems } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => items),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
value: 'img_001.png'
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
updateSelectedItems(new Set())
expect(modelValue.value).toBeUndefined()
expect(mockCheckState).toHaveBeenCalledOnce()
})
})
describe('handleFilesUpdate', () => {
it('uploads file and updates modelValue', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
})
)
const modelValue = ref<string | undefined>('img_001.png')
const items = createItems('img_001.png')
const widgetValues = ['img_001.png']
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => items),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
value: 'img_001.png',
options: { values: widgetValues }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
const file = new File(['test'], 'uploaded.png', {
type: 'image/png'
})
await handleFilesUpdate([file])
expect(modelValue.value).toBe('uploaded.png')
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('adds uploaded path to widget values array', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 200,
json: () => Promise.resolve({ name: 'new.png', subfolder: '' })
})
)
const modelValue = ref<string | undefined>()
const widgetValues = ['existing.png']
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => []),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
options: { values: widgetValues }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
await handleFilesUpdate([new File(['test'], 'new.png')])
expect(widgetValues).toContain('new.png')
expect(widgetValues).toHaveLength(2)
})
it('calls widget callback after upload', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
})
)
const mockCallback = vi.fn()
const modelValue = ref<string | undefined>()
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => []),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
callback: mockCallback,
options: { values: [] }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
await handleFilesUpdate([new File(['test'], 'uploaded.png')])
expect(mockCallback).toHaveBeenCalledWith('uploaded.png')
})
it('shows alert toast on upload failure', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue(
fromPartial<Response>({
status: 500,
statusText: 'Internal Server Error'
})
)
const modelValue = ref<string | undefined>('original.png')
const { handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems: computed(() => []),
widget: () =>
fromPartial<SimplifiedWidget<string | undefined>>({
name: 'test',
type: 'combo',
options: { values: [] }
}),
uploadFolder: () => 'input',
uploadSubfolder: () => undefined
})
await handleFilesUpdate([new File(['test'], 'fail.png')])
expect(modelValue.value).toBe('original.png')
const toastStore = useToastStore()
expect(toastStore.addAlert).toHaveBeenCalledWith(
'500 - Internal Server Error'
)
})
})
})

View File

@@ -0,0 +1,120 @@
import { toValue } from 'vue'
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface UseWidgetSelectActionsOptions {
modelValue: Ref<string | undefined>
dropdownItems: ComputedRef<FormDropdownItem[]>
widget: MaybeRefOrGetter<SimplifiedWidget<string | undefined>>
uploadFolder: MaybeRefOrGetter<ResultItemType | undefined>
uploadSubfolder: MaybeRefOrGetter<string | undefined>
}
export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const { modelValue, dropdownItems } = options
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
function checkWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
function updateSelectedItems(selectedItems: Set<string>) {
const id =
selectedItems.size > 0 ? selectedItems.values().next().value : undefined
const name =
id == null
? undefined
: dropdownItems.value.find((item) => item.id === id)?.name
modelValue.value = name
checkWorkflowState()
}
async function uploadFile(
file: File,
isPasted: boolean = false,
formFields: Partial<{ type: ResultItemType }> = {}
) {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
else {
const subfolder = toValue(options.uploadSubfolder)
if (subfolder) body.append('subfolder', subfolder)
}
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
toastStore.addAlert(resp.status + ' - ' + resp.statusText)
return null
}
const data = await resp.json()
if (formFields.type === 'input' || (!formFields.type && !isPasted)) {
const assetsStore = useAssetsStore()
await assetsStore.updateInputs()
}
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
async function uploadFiles(files: File[]): Promise<string[]> {
const folder = toValue(options.uploadFolder) ?? 'input'
const uploadPromises = files.map((file) =>
uploadFile(file, false, { type: folder })
)
const results = await Promise.all(uploadPromises)
return results.filter((path): path is string => path !== null)
}
const handleFilesUpdate = wrapWithErrorHandlingAsync(
async (files: File[]) => {
if (!files || files.length === 0) return
const uploadedPaths = await uploadFiles(files)
if (uploadedPaths.length === 0) {
toastStore.addAlert('File upload failed')
return
}
const widget = toValue(options.widget)
const values = widget.options?.values
if (Array.isArray(values)) {
uploadedPaths.forEach((path) => {
if (!values.includes(path)) {
values.push(path)
}
})
}
modelValue.value = uploadedPaths[0]
if (widget.callback) {
widget.callback(uploadedPaths[0])
}
checkWorkflowState()
}
)
return {
updateSelectedItems,
handleFilesUpdate
}
}

View File

@@ -0,0 +1,668 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { computed, nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
() => ({
useAssetWidgetData: () => ({
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
})
})
)
const mockResolveOutputAssetItems = vi.fn()
function createMockMediaAssets() {
return {
media: ref<AssetItem[]>([]),
loading: ref(false),
error: ref(null),
fetchMediaList: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue([]),
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
}
}
let mockMediaAssets = createMockMediaAssets()
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => mockMediaAssets
}))
vi.mock('@/platform/assets/composables/useAssetFilterOptions', () => ({
useAssetFilterOptions: () => ({
ownershipOptions: computed(() => []),
availableBaseModels: computed(() => []),
availableFileFormats: computed(() => [])
})
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
}))
function createDefaultOptions(
overrides: Partial<Parameters<typeof useWidgetSelectItems>[0]> = {}
) {
return {
values: () => ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
getOptionLabel: () =>
undefined as ((value?: string | null) => string) | undefined,
modelValue: ref<string | undefined>('img_001.png'),
assetKind: () => 'image' as const,
outputMediaAssets: mockMediaAssets,
assetData: null,
isAssetMode: () => false,
...overrides
}
}
describe('display label behavior', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('uses values as labels when no label function provided', () => {
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
expect(dropdownItems.value[0]).toMatchObject({
name: 'img_001.png',
label: 'img_001.png'
})
})
it('applies custom label function', () => {
const getOptionLabel = (v?: string | null) => `Custom: ${v}`
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[0].label).toBe('Custom: img_001.png')
})
it('falls back to value on label function error', () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
const getOptionLabel = (v?: string | null) => {
if (v === 'photo_abc.jpg') throw new Error('fail')
return `Labeled: ${v}`
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[0].label).toBe('Labeled: img_001.png')
expect(dropdownItems.value[1].label).toBe('photo_abc.jpg')
expect(dropdownItems.value[2].label).toBe('Labeled: hash789.png')
expect(consoleWarnSpy).toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it('falls back to value when label function returns empty string', () => {
const getOptionLabel = (v?: string | null) => {
if (v === 'photo_abc.jpg') return ''
return `Labeled: ${v}`
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[1].label).toBe('photo_abc.jpg')
})
it('falls back to value when label function returns undefined', () => {
const getOptionLabel = (v?: string | null) => {
if (v === 'hash789.png') return undefined as unknown as string
return `Labeled: ${v}`
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({ getOptionLabel: () => getOptionLabel })
)
expect(dropdownItems.value[2].label).toBe('hash789.png')
})
})
describe('useWidgetSelectItems', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockMediaAssets = createMockMediaAssets()
mockResolveOutputAssetItems.mockReset()
mockAssetsData.items = []
})
describe('dropdownItems', () => {
it('maps values to items with names as labels', () => {
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
expect(dropdownItems.value).toHaveLength(3)
expect(dropdownItems.value[0]).toMatchObject({
name: 'img_001.png',
label: 'img_001.png'
})
})
it('returns empty when values is undefined and no modelValue', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => undefined,
modelValue: ref(undefined)
})
)
expect(dropdownItems.value).toHaveLength(0)
})
})
describe('missing value handling', () => {
it('creates fallback item when modelValue not in inputs', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('template_image.png')
})
)
expect(
dropdownItems.value.some((item) => item.name === 'template_image.png')
).toBe(true)
expect(dropdownItems.value[0].id).toBe('missing-template_image.png')
})
it('does not include fallback when filter is inputs', async () => {
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('template_image.png')
})
)
filterSelected.value = 'inputs'
await nextTick()
expect(dropdownItems.value).toHaveLength(2)
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
it('does not include fallback when filter is outputs', async () => {
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('template_image.png')
})
)
filterSelected.value = 'outputs'
await nextTick()
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
it('no fallback when modelValue exists in inputs', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref('img_001.png')
})
)
expect(dropdownItems.value).toHaveLength(2)
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
it('no fallback when modelValue is undefined', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png', 'photo_abc.jpg'],
modelValue: ref(undefined)
})
)
expect(dropdownItems.value).toHaveLength(2)
expect(
dropdownItems.value.every(
(item) => !String(item.id).startsWith('missing-')
)
).toBe(true)
})
})
describe('cloud asset mode', () => {
const createTestAsset = (
id: string,
name: string,
preview_url: string
): AssetItem => ({
id,
name,
preview_url,
tags: []
})
it('excludes missing items from cloud dropdown', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('missing_model.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].name).toBe('existing_model.safetensors')
})
it('shows only available cloud assets', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'model_a.safetensors',
'https://example.com/a.jpg'
),
createTestAsset(
'asset-2',
'model_b.safetensors',
'https://example.com/b.jpg'
)
]
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('model_a.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(dropdownItems.value).toHaveLength(2)
expect(dropdownItems.value.map((i) => i.name)).toEqual([
'model_a.safetensors',
'model_b.safetensors'
])
})
it('returns empty dropdown when no cloud assets', () => {
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => [] as AssetItem[]),
isLoading: computed(() => false),
error: computed(() => null)
}
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('missing.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(dropdownItems.value).toHaveLength(0)
})
it('includes missing cloud asset in displayItems', () => {
mockAssetsData.items = [
createTestAsset(
'asset-1',
'existing_model.safetensors',
'https://example.com/preview.jpg'
)
]
const assetData = {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
}
const { displayItems, selectedSet } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('missing_model.safetensors'),
assetKind: () => 'model',
isAssetMode: () => true,
assetData
})
)
expect(displayItems.value).toHaveLength(2)
expect(displayItems.value[0].name).toBe('missing_model.safetensors')
expect(displayItems.value[0].id).toBe('missing-missing_model.safetensors')
expect(selectedSet.value.has('missing-missing_model.safetensors')).toBe(
true
)
})
})
describe('multi-output jobs', () => {
function makeMultiOutputAsset(
jobId: string,
name: string,
nodeId: string,
outputCount: number
) {
return {
id: jobId,
name,
preview_url: `/api/view?filename=${name}&type=output`,
tags: ['output'],
user_metadata: {
jobId,
nodeId,
subfolder: '',
outputCount,
allOutputs: [
{
filename: name,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
]
}
}
}
it('shows all outputs after resolving multi-output jobs', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'job-1-5-output_001.png',
name: 'output_001.png',
preview_url: '/api/view?filename=output_001.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_002.png',
name: 'output_002.png',
preview_url: '/api/view?filename=output_002.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_003.png',
name: 'output_003.png',
preview_url: '/api/view?filename=output_003.png&type=output',
tags: ['output']
}
])
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('output_001.png')
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(dropdownItems.value).toHaveLength(3)
})
expect(dropdownItems.value.map((i) => i.name)).toEqual([
'output_001.png [output]',
'output_002.png [output]',
'output_003.png [output]'
])
})
it('shows preview when job has only one output', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
]
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref('single.png')
})
)
filterSelected.value = 'outputs'
await nextTick()
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].name).toBe('single.png [output]')
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
})
it('resolves two multi-output jobs independently', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
]
mockResolveOutputAssetItems.mockImplementation(
async (meta: { jobId: string }) => {
if (meta.jobId === 'job-A') {
return [
{
id: 'A-1',
name: 'a1.png',
preview_url: '',
tags: ['output']
},
{
id: 'A-2',
name: 'a2.png',
preview_url: '',
tags: ['output']
}
]
}
return [
{
id: 'B-1',
name: 'b1.png',
preview_url: '',
tags: ['output']
},
{
id: 'B-2',
name: 'b2.png',
preview_url: '',
tags: ['output']
}
]
}
)
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(dropdownItems.value).toHaveLength(4)
})
const names = dropdownItems.value.map((i) => i.name)
expect(names).toContain('a1.png [output]')
expect(names).toContain('a2.png [output]')
expect(names).toContain('b1.png [output]')
expect(names).toContain('b2.png [output]')
})
it('resolves outputs when allOutputs already contains all items', async () => {
mockMediaAssets.media.value = [
{
id: 'job-complete',
name: 'preview.png',
preview_url: '/api/view?filename=preview.png&type=output',
tags: ['output'],
user_metadata: {
jobId: 'job-complete',
nodeId: '1',
subfolder: '',
outputCount: 2,
allOutputs: [
{
filename: 'out1.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
{
filename: 'out2.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
]
}
}
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'c-1',
name: 'out1.png',
preview_url: '',
tags: ['output']
},
{
id: 'c-2',
name: 'out2.png',
preview_url: '',
tags: ['output']
}
])
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(dropdownItems.value).toHaveLength(2)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-complete' }),
expect.any(Object)
)
const names = dropdownItems.value.map((i) => i.name)
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
})
it('falls back to preview when resolver rejects', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
]
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined)
})
)
filterSelected.value = 'outputs'
await vi.waitFor(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to resolve multi-output job',
'job-fail',
expect.any(Error)
)
})
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].name).toBe('preview.png [output]')
consoleWarnSpy.mockRestore()
})
})
describe('selectedSet', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty set when modelValue is undefined', () => {
const { selectedSet } = useWidgetSelectItems(
createDefaultOptions({
modelValue: ref(undefined)
})
)
expect(selectedSet.value.size).toBe(0)
})
it('returns set with matching item id when modelValue matches', () => {
const { selectedSet } = useWidgetSelectItems(
createDefaultOptions({
modelValue: ref('img_001.png')
})
)
expect(selectedSet.value.size).toBe(1)
expect(selectedSet.value.has('input-0')).toBe(true)
})
it('returns set with missing item id when modelValue matches no input', () => {
const { selectedSet } = useWidgetSelectItems(
createDefaultOptions({
modelValue: ref('nonexistent.png'),
values: () => ['img_001.png']
})
)
expect(selectedSet.value.size).toBe(1)
expect(selectedSet.value.has('missing-nonexistent.png')).toBe(true)
})
})
})

View File

@@ -0,0 +1,314 @@
import { capitalize } from 'es-toolkit'
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import {
filterItemByBaseModels,
filterItemByOwnership
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import type {
FilterOption,
OwnershipOption
} from '@/platform/assets/types/filterTypes'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import type { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import type { AssetKind } from '@/types/widgetTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
function getDisplayLabel(
value: string,
getOptionLabel?: ((value?: string | null) => string) | undefined
): string {
if (!getOptionLabel) return value
try {
return getOptionLabel(value) || value
} catch (e) {
console.warn('Failed to map value:', e)
return value
}
}
function assetKindToMediaType(kind: AssetKind): string {
return kind === 'mesh' ? '3D' : kind
}
function getMediaUrl(
filename: string,
type: 'input' | 'output',
assetKind: AssetKind | undefined
): string {
if (!['image', 'video', 'audio', 'mesh'].includes(assetKind ?? '')) return ''
const params = new URLSearchParams({ filename, type })
appendCloudResParam(params, filename)
return `/api/view?${params}`
}
interface UseWidgetSelectItemsOptions {
values: MaybeRefOrGetter<unknown[] | undefined>
getOptionLabel: MaybeRefOrGetter<
((value?: string | null) => string) | undefined
>
modelValue: Ref<string | undefined>
assetKind: MaybeRefOrGetter<AssetKind | undefined>
outputMediaAssets: ReturnType<typeof useMediaAssets>
assetData: ReturnType<typeof useAssetWidgetData> | null
isAssetMode: MaybeRefOrGetter<boolean | undefined>
}
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const { modelValue, outputMediaAssets, assetData } = options
const filterSelected = ref('all')
const filterOptions = computed<FilterOption[]>(() => {
const isAsset = toValue(options.isAssetMode)
if (isAsset) {
const categoryName = assetData?.category.value ?? 'All'
return [{ name: capitalize(categoryName), value: 'all' }]
}
return [
{ name: 'All', value: 'all' },
{ name: 'Inputs', value: 'inputs' },
{ name: 'Outputs', value: 'outputs' }
]
})
const ownershipSelected = ref<OwnershipOption>('all')
const showOwnershipFilter = computed(() => !!toValue(options.isAssetMode))
const { ownershipOptions, availableBaseModels } = useAssetFilterOptions(
() => assetData?.assets.value ?? []
)
const baseModelSelected = ref<Set<string>>(new Set())
const showBaseModelFilter = computed(() => !!toValue(options.isAssetMode))
const baseModelOptions = computed<FilterOption[]>(() => {
if (!toValue(options.isAssetMode) || !assetData) return []
return availableBaseModels.value
})
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
const pendingJobIds = new Set<string>()
watch(
() => outputMediaAssets.media.value,
(assets, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
pendingJobIds.clear()
})
for (const asset of assets) {
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
if (
outputCount <= 1 ||
resolvedByJobId.value.has(meta.jobId) ||
pendingJobIds.has(meta.jobId)
)
continue
pendingJobIds.add(meta.jobId)
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
.then((resolved) => {
if (cancelled || !resolved.length) return
const next = new Map(resolvedByJobId.value)
next.set(meta.jobId, resolved)
resolvedByJobId.value = next
})
.catch((error) => {
console.warn(
'Failed to resolve multi-output job',
meta.jobId,
error
)
})
.finally(() => {
pendingJobIds.delete(meta.jobId)
})
}
},
{ immediate: true }
)
const inputItems = computed<FormDropdownItem[]>(() => {
const values = toValue(options.values) || []
if (!Array.isArray(values)) return []
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
return values.map((value, index) => ({
id: `input-${index}`,
preview_url: getMediaUrl(String(value), 'input', kind),
name: String(value),
label: getDisplayLabel(String(value), labelFn)
}))
})
const outputItems = computed<FormDropdownItem[]>(() => {
const kind = toValue(options.assetKind)
if (!['image', 'video', 'audio', 'mesh'].includes(kind ?? '')) return []
const targetMediaType = assetKindToMediaType(kind!)
const seen = new Set<string>()
const items: FormDropdownItem[] = []
const labelFn = toValue(options.getOptionLabel)
const assets = outputMediaAssets.media.value.flatMap((asset) => {
const meta = getOutputAssetMetadata(asset.user_metadata)
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
return resolved ?? [asset]
})
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
seen.add(asset.id)
const annotatedPath = `${asset.name} [output]`
items.push({
id: `output-${asset.id}`,
preview_url:
asset.preview_url || getMediaUrl(asset.name, 'output', kind),
name: annotatedPath,
label: getDisplayLabel(annotatedPath, labelFn)
})
}
return items
})
const missingValueItem = computed<FormDropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
if (toValue(options.isAssetMode) && assetData) {
const existsInAssets = assetData.assets.value.some(
(asset) => getAssetFilename(asset) === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
preview_url: '',
name: currentValue,
label: getDisplayLabel(currentValue, labelFn)
}
}
const existsInInputs = inputItems.value.some(
(item) => item.name === currentValue
)
const existsInOutputs = outputItems.value.some(
(item) => item.name === currentValue
)
if (existsInInputs || existsInOutputs) return undefined
const isOutput = currentValue.endsWith(' [output]')
const strippedValue = isOutput
? currentValue.replace(' [output]', '')
: currentValue
return {
id: `missing-${currentValue}`,
preview_url: getMediaUrl(
strippedValue,
isOutput ? 'output' : 'input',
kind
),
name: currentValue,
label: getDisplayLabel(currentValue, labelFn)
}
})
const assetItems = computed<FormDropdownItem[]>(() => {
if (!toValue(options.isAssetMode) || !assetData) return []
return assetData.assets.value.map((asset) => ({
id: asset.id,
name: getAssetFilename(asset),
label: getAssetDisplayName(asset),
preview_url: asset.preview_url,
is_immutable: asset.is_immutable,
base_models: getAssetBaseModels(asset)
}))
})
const filteredAssetItems = computed<FormDropdownItem[]>(() =>
filterItemByBaseModels(
filterItemByOwnership(assetItems.value, ownershipSelected.value),
baseModelSelected.value
)
)
const allItems = computed<FormDropdownItem[]>(() => {
if (toValue(options.isAssetMode) && assetData) {
return filteredAssetItems.value
}
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
]
})
const dropdownItems = computed<FormDropdownItem[]>(() => {
if (toValue(options.isAssetMode)) {
return allItems.value
}
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
case 'outputs':
return outputItems.value
case 'all':
default:
return allItems.value
}
})
const displayItems = computed<FormDropdownItem[]>(() => {
if (toValue(options.isAssetMode) && assetData && missingValueItem.value) {
return [missingValueItem.value, ...filteredAssetItems.value]
}
return dropdownItems.value
})
const selectedSet = computed<Set<string>>(() => {
const currentValue = modelValue.value
if (currentValue === undefined) return new Set()
const item = displayItems.value.find((item) => item.name === currentValue)
return item ? new Set([item.id]) : new Set()
})
return {
dropdownItems,
displayItems,
filterSelected,
filterOptions,
ownershipSelected,
showOwnershipFilter,
ownershipOptions,
baseModelSelected,
showBaseModelFilter,
baseModelOptions,
selectedSet
}
}

View File

@@ -36,7 +36,7 @@ export function isTransparent(color: string) {
return false
}
function rgbToHsl({ r, g, b }: RGB): HSL {
export function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
g /= 255
b /= 255