Compare commits

..

4 Commits

78 changed files with 1965 additions and 4115 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": "error",
"playwright/no-force-option": "off",
"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().catch(() => {})
await btn.click({ force: true }).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()
await tab.locator('.close-button').click({ force: true })
}
getSaveDialog(): Locator {

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 77 KiB

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()
await helpButton.click({ force: true })
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 44 KiB

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()
await deleteButton.click({ force: true })
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()
await infoButton.click({ force: true })
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()
await deleteButton.click({ force: true })
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()
await bypassButton.click({ force: true })
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()
await bypassButton.click({ force: true })
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()
await convertButton.click({ force: true })
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()
await convertButton.click({ force: true })
await comfyPage.nextFrame()
await expect
@@ -200,14 +200,13 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
await expect(
comfyPage.selectionToolbox.getByRole('button', {
name: /Frame Nodes/i
})
).toBeVisible()
await comfyPage.selectionToolbox
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await comfyPage.page
.getByRole('button', { name: /Frame Nodes/i })
.click()
.click({ force: true })
await comfyPage.nextFrame()
await expect

View File

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

View File

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

View File

@@ -94,7 +94,6 @@ 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()
}
@@ -193,7 +192,6 @@ 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()
@@ -220,7 +218,6 @@ 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,8 +22,10 @@ test.describe('Vue Integer Widget', () => {
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await expect(controls.incrementButton).toBeDisabled()
await expect(controls.decrementButton).toBeDisabled()
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
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 `@testing-library/vue` with `@testing-library/user-event` for component tests (an ESLint rule bans `@vue/test-utils` in new tests)
- Use Vue Test Utils for component 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 (legacy; new tests must use @testing-library/vue)
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started

View File

@@ -1,7 +1,5 @@
# 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,23 +432,6 @@ 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.3",
"version": "1.44.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -150,6 +150,7 @@
"@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,6 +171,9 @@ 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
@@ -690,6 +693,9 @@ 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,6 +58,7 @@ 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

@@ -83,16 +83,30 @@ const updateWidgets = () => {
if (widgetState.visible) {
const margin = widget.margin
widgetState.pos = [
posNode.pos[0] + margin,
posNode.pos[1] + margin + posWidget.y
]
widgetState.size = [
(posWidget.width ?? posNode.width) - margin * 2,
(posWidget.computedHeight ?? 50) - margin * 2
]
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
widgetState.readonly = lgCanvas.read_only
const newPosX = posNode.pos[0] + margin
const newPosY = posNode.pos[1] + margin + posWidget.y
if (widgetState.pos[0] !== newPosX || widgetState.pos[1] !== newPosY) {
widgetState.pos = [newPosX, newPosY]
}
const newWidth = (posWidget.width ?? posNode.width) - margin * 2
const newHeight = (posWidget.computedHeight ?? 50) - margin * 2
if (
widgetState.size[0] !== newWidth ||
widgetState.size[1] !== newHeight
) {
widgetState.size = [newWidth, newHeight]
}
const newZIndex = getDomWidgetZIndex(posNode, currentGraph)
if (widgetState.zIndex !== newZIndex) {
widgetState.zIndex = newZIndex
}
const newReadonly = lgCanvas.read_only
if (widgetState.readonly !== newReadonly) {
widgetState.readonly = newReadonly
}
}
}

View File

@@ -121,16 +121,27 @@ function composeStyle() {
}
}
watch([() => widgetState.pos, () => widgetState.size, left, top], () => {
updatePosition(widgetState)
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
})
watch(
[() => widgetState, left, top, enableDomClipping],
([widgetState]) => {
updatePosition(widgetState)
[
() => widgetState.zIndex,
() => widgetState.readonly,
() => widgetState.positionOverride,
enableDomClipping
],
() => {
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
},
{ deep: true }
}
)
// Recompose style when clippingStyle updates asynchronously via RAF.

View File

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

View File

@@ -6,21 +6,19 @@
@pointerup.stop
@wheel.stop
>
<div class="relative">
<div class="show-menu relative">
<Button
ref="menuTriggerRef"
variant="textonly"
size="icon"
:aria-label="$t('menu.showMenu')"
class="rounded-full"
@click="toggleMenu"
>
<i class="icon-[lucide--menu] text-lg text-base-foreground" />
<i class="pi pi-bars 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">
@@ -44,6 +42,7 @@
</div>
</div>
</div>
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<SceneControls
v-if="showSceneControls"
@@ -52,9 +51,6 @@
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"
/>
@@ -74,19 +70,11 @@
v-model:fov="cameraConfig!.fov"
/>
<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>
<LightControls
v-if="showLightControls"
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
/>
<ExportControls
v-if="showExportControls"
@@ -97,12 +85,10 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, onUnmounted, 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'
@@ -131,17 +117,6 @@ 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',
@@ -185,26 +160,21 @@ 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 icon =
category in categoryIcons
? categoryIcons[category as keyof typeof categoryIcons]
: 'icon-[lucide--circle]'
return cn(icon, 'text-lg text-base-foreground')
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 emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'updateHdriFile', file: File | null): void
}>()
const handleBackgroundImageUpdate = (file: File | null) => {
@@ -215,7 +185,19 @@ const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const handleHDRIFileUpdate = (file: File | null) => {
emit('updateHdriFile', file)
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-menu')) {
isMenuOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeSlider)
})
onUnmounted(() => {
document.removeEventListener('click', closeSlider)
})
</script>

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ 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,
@@ -48,8 +47,39 @@ const rgbToHSL = (
g: number,
b: number
): { h: number; s: number; l: number } => {
const hsl = rgbToHsl({ r, g, b })
return { h: hsl.h * 360, s: hsl.s * 100, l: hsl.l * 100 }
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 rgbToLab = (rgb: {

View File

@@ -23,17 +23,7 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: 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))
}
)
uploadFile: vi.fn()
}
}))
@@ -82,13 +72,7 @@ describe('useLoad3d', () => {
state: null
},
'Light Config': {
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
intensity: 5
},
'Resource Folder': ''
},
@@ -138,11 +122,6 @@ 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(),
@@ -188,13 +167,7 @@ describe('useLoad3d', () => {
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
intensity: 5
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
@@ -503,7 +476,7 @@ describe('useLoad3d', () => {
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
expect(mockNode.properties['Light Config']).toMatchObject({
expect(mockNode.properties['Light Config']).toEqual({
intensity: 10
})
})
@@ -939,97 +912,6 @@ 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,7 +1,6 @@
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'
@@ -25,7 +24,6 @@ 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'
@@ -60,15 +58,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
})
const lightConfig = ref<LightConfig>({
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
intensity: 5
})
const lastNonHdriLightIntensity = ref(lightConfig.value.intensity)
const isRecording = ref(false)
const hasRecording = ref(false)
@@ -194,45 +185,8 @@ 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 = {
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 }
}
lightConfig.value = savedLightConfig
}
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
@@ -259,39 +213,6 @@ 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 => {
@@ -339,44 +260,22 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
watch(
sceneConfig,
(newValue) => {
if (nodeRef.value) {
async (newValue) => {
if (load3d && 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) => {
@@ -403,54 +302,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
)
watch(
() => lightConfig.value.intensity,
(intensity) => {
if (!load3d || !nodeRef.value) return
if (!lightConfig.value.hdri?.enabled) {
lastNonHdriLightIntensity.value = intensity
lightConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Light Config'] = newValue
load3d.setLightIntensity(newValue.intensity)
}
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)
}
},
{ deep: true }
)
watch(playing, (newValue) => {
@@ -518,98 +377,6 @@ 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 = ''
@@ -875,7 +642,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleHDRIFileUpdate,
handleExportModel,
handleModelDrop,
cleanup

View File

@@ -295,7 +295,7 @@ useExtensionService().registerExtension({
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = 'none'
modelWidget.value = ''
}
})

View File

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

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

View File

@@ -3,7 +3,6 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
CameraState,
HDRIConfig,
LightConfig,
ModelConfig,
SceneConfig
@@ -75,7 +74,7 @@ class Load3DConfiguration {
loadFolder,
cameraState
)
if (modelWidget.value && modelWidget.value !== 'none') {
if (modelWidget.value) {
onModelWidgetUpdate(modelWidget.value)
}
@@ -114,7 +113,6 @@ class Load3DConfiguration {
const lightConfig = this.loadLightConfig()
this.applyLightConfig(lightConfig)
if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri)
}
private loadSceneConfig(): SceneConfig {
@@ -142,27 +140,13 @@ class Load3DConfiguration {
}
private loadLightConfig(): LightConfig {
const hdriDefaults: HDRIConfig = {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
if (this.properties && 'Light Config' in this.properties) {
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 this.properties['Light Config'] as LightConfig
}
return {
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number,
hdri: hdriDefaults
}
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
} as LightConfig
}
private loadModelConfig(): ModelConfig {
@@ -206,15 +190,6 @@ 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)
@@ -226,10 +201,7 @@ class Load3DConfiguration {
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value || value === 'none') {
this.load3d.clearModel()
return
}
if (!value) return
const filename = value as string

View File

@@ -6,7 +6,6 @@ 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'
@@ -55,7 +54,6 @@ class Load3d {
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
@@ -128,12 +126,6 @@ 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),
@@ -643,33 +635,6 @@ 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
@@ -893,7 +858,6 @@ 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

@@ -1,25 +0,0 @@
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,15 +89,6 @@ 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) {
@@ -131,19 +122,6 @@ 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,9 +16,3 @@ 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,14 +47,6 @@ 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

@@ -1,198 +0,0 @@
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,7 +7,6 @@ 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'
@@ -4271,17 +4270,6 @@ 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

@@ -1988,16 +1988,7 @@
"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...",
"loadingHDRI": "Loading HDRI...",
"hdri": {
"label": "HDRI Environment",
"uploadFile": "Upload HDRI (.hdr, .exr)",
"changeFile": "Change HDRI",
"removeFile": "Remove HDRI",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
}
"uploadingModel": "Uploading 3D model..."
},
"imageCrop": {
"loading": "Loading...",
@@ -2092,9 +2083,7 @@
"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}",
"failedToLoadHDRI": "Failed to load HDRI file",
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}"
},
"nodeErrors": {
"render": "Node Render Error",

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
/* 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'
@@ -11,6 +13,7 @@ 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', () => ({
@@ -95,6 +98,35 @@ 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()
@@ -123,6 +155,19 @@ 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', () => {
@@ -273,6 +318,54 @@ 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,16 +80,56 @@
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
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 { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
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 { cn } from '@/utils/tailwindUtil'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
import InputSlot from './InputSlot.vue'
@@ -101,7 +141,12 @@ 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
@@ -115,6 +160,8 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -126,11 +173,314 @@ onErrorCaptured((error) => {
return false
})
const {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
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(' ')
})
</script>

View File

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

@@ -1,431 +0,0 @@
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,14 +1,17 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { computed, nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { computed } from 'vue'
import type { ComponentPublicInstance } 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'
@@ -52,7 +55,7 @@ vi.mock(
})
)
const { mockMediaAssets } = vi.hoisted(() => {
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
@@ -65,7 +68,8 @@ const { mockMediaAssets } = vi.hoisted(() => {
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
}
},
mockResolveOutputAssetItems: vi.fn()
}
})
@@ -74,187 +78,732 @@ vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: vi.fn().mockResolvedValue([])
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
}))
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: {} }
})
describe('WidgetSelectDropdown', () => {
beforeEach(() => {
mockMediaAssets.media.value = []
mockCheckState.mockClear()
mockAssetsData.items = []
mockItemsRef.value = []
mockSelectedSetRef.value = new Set()
mockFilterSelectedRef.value = 'all'
mockUpdateSelectedItems.mockClear()
mockHandleFilesUpdate.mockClear()
})
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: FormDropdownItem[]
outputItems: FormDropdownItem[]
dropdownItems: FormDropdownItem[]
filterSelected: string
updateSelectedItems: (selectedSet: Set<string>) => void
}
function renderComponent(
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,
extraProps: Record<string, unknown> = {}
) {
return render(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input',
...extraProps
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
})
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]
}
})
)
}
it('renders the dropdown component', () => {
mockItemsRef.value = [
{ id: 'input-0', name: 'img_001.png' },
{ id: 'input-1', name: 'photo_abc.jpg' }
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'
)
]
mockSelectedSetRef.value = new Set(['input-0'])
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: [] }
})
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()
})
it('calls checkState after dropdown selection changes modelValue', () => {
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'] }
})
renderComponent(widget, 'img_001.png')
expect(screen.getByText('img_001.png')).toBeDefined()
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()
})
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'])
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)
const widget = createMockWidget<string | undefined>({
value: 'model_a.safetensors',
name: 'test_model',
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: {
values: [],
nodeType: 'CheckpointLoaderSimple'
}
options: { values: ['img_001.png'] }
})
renderComponent(widget, 'model_a.safetensors', {
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
})
expect(screen.getByText('model_a.safetensors')).toBeDefined()
})
const wrapper = mountForUndo(widget, 'img_001.png')
describe('composable wiring', () => {
const items: FormDropdownItem[] = [
{ id: 'input-0', name: 'cat.png', label: 'cat.png' },
{ id: 'input-1', name: 'dog.png', label: 'dog.png' }
]
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
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()
})
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
})

View File

@@ -1,20 +1,45 @@
<script setup lang="ts">
import { computed, provide, ref, toRef } from 'vue'
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, shallowRef, toRef, watch } 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 { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type {
FormDropdownItem,
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 { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions'
import { useWidgetSelectItems } from '@/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
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
@@ -46,6 +71,7 @@ const modelValue = defineModel<string | undefined>({
})
const { t } = useI18n()
const toastStore = useToastStore()
const outputMediaAssets = useMediaAssets('output')
@@ -66,34 +92,261 @@ const getAssetData = () => {
}
const assetData = getAssetData()
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 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 { updateSelectedItems, handleFilesUpdate } = useWidgetSelectActions({
modelValue,
dropdownItems,
widget: () => props.widget,
uploadFolder: () => props.uploadFolder,
uploadSubfolder: () => props.uploadSubfolder
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 mediaPlaceholder = computed(() => {
@@ -139,12 +392,141 @@ const acceptTypes = computed(() => {
case 'mesh':
return SUPPORTED_EXTENSIONS_ACCEPT
default:
return undefined
return undefined // model or unknown
}
})
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()
@@ -155,11 +537,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

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

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

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

@@ -1,314 +0,0 @@
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
}
export function rgbToHsl({ r, g, b }: RGB): HSL {
function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
g /= 255
b /= 255