Compare commits
11 Commits
feat/model
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff90de25d | ||
|
|
d94fb3c1b7 | ||
|
|
e46be82b88 | ||
|
|
e7019146d1 | ||
|
|
0f27431c37 | ||
|
|
f8187cec4c | ||
|
|
7e61358724 | ||
|
|
ff9e6415b5 | ||
|
|
c695aa1ee0 | ||
|
|
b907423526 | ||
|
|
7d99189211 |
@@ -1,4 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
@@ -91,3 +91,21 @@ export async function measureSelectionBounds(
|
||||
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
|
||||
export async function intersection(a: Locator, b: Locator) {
|
||||
const aBounds = await a.boundingBox()
|
||||
const bBounds = await b.boundingBox()
|
||||
if (!aBounds || !bBounds) return undefined
|
||||
|
||||
const y = Math.max(aBounds.y, bBounds.y)
|
||||
const x = Math.max(aBounds.x, bBounds.x)
|
||||
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
|
||||
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
|
||||
|
||||
if (y > bot || x > right) return undefined
|
||||
|
||||
const width = right - x
|
||||
const height = bot - y
|
||||
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
63
browser_tests/tests/load3d/load3dSerializeCache.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
type Load3dImageInput = {
|
||||
image: string
|
||||
mask: string
|
||||
normal: string
|
||||
recording: string
|
||||
}
|
||||
|
||||
type PromptBody = {
|
||||
prompt?: Record<
|
||||
string,
|
||||
{ class_type?: string; inputs?: Record<string, unknown> }
|
||||
>
|
||||
}
|
||||
|
||||
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
|
||||
const prompt = (body as PromptBody).prompt ?? {}
|
||||
const node = prompt[nodeId]
|
||||
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
|
||||
const input = node!.inputs!.image as Load3dImageInput
|
||||
expect(typeof input.image).toBe('string')
|
||||
expect(typeof input.recording).toBe('string')
|
||||
return input
|
||||
}
|
||||
|
||||
test.describe('Load3D serialize cache', () => {
|
||||
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
|
||||
comfyPage,
|
||||
load3d
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
let firstBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
firstBody = body
|
||||
}
|
||||
})
|
||||
const firstInput = getLoad3dImageInput(firstBody, '1')
|
||||
expect(firstInput.recording).toBe('')
|
||||
|
||||
await load3d.recordingButton.click()
|
||||
await expect(load3d.stopRecordingButton).toBeVisible()
|
||||
|
||||
let secondBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
secondBody = body
|
||||
}
|
||||
})
|
||||
const secondInput = getLoad3dImageInput(secondBody, '1')
|
||||
|
||||
expect(
|
||||
secondInput.image,
|
||||
'after starting a recording, the next queue must re-capture ' +
|
||||
'(image filename must change) so the recording is not dropped'
|
||||
).not.toBe(firstInput.image)
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should filter execution errors by search query', async ({
|
||||
test('Should keep execution errors matching the search query', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
|
||||
await searchInput.fill('nonexistent_query_xyz_12345')
|
||||
await searchInput.fill('Execution failed')
|
||||
|
||||
await expect(runtimePanel).toHaveCount(0)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show error message in runtime error panel', async ({
|
||||
test('Should show runtime error log in the execution error group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openExecutionErrorTab(comfyPage)
|
||||
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.runtimeErrorPanel
|
||||
)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
await expect(runtimePanel).toContainText(/\S/)
|
||||
await expect(runtimePanel).toContainText('Error log')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
30
browser_tests/tests/textareaWidgetFontSize.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Textarea widget font size',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('14px')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('22px')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -180,4 +180,44 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'requests lightweight thumbnail URLs for grid cells',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
await expect(previewImage).toBeVisible()
|
||||
})
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
const gridImages = node.imageGrid.locator('img')
|
||||
|
||||
await test.step('Inject a multi-image grid', async () => {
|
||||
const images = Array.from({ length: 4 }, (_, index) => ({
|
||||
filename: `grid-${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}))
|
||||
execution.executed('', '1', { images })
|
||||
await expect(gridImages).toHaveCount(4)
|
||||
})
|
||||
|
||||
// FE-741: small on-node grid cells must request a server re-encoded
|
||||
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
|
||||
// downloading the full-resolution image, while still pointing at the
|
||||
// real `/api/view` URL for that output. Verifies the full path: WS
|
||||
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
|
||||
// rendered grid `<img>`.
|
||||
for (const cell of await gridImages.all()) {
|
||||
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
|
||||
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 104 KiB |
@@ -1,10 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
|
||||
|
||||
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
async function openSamplerDropdown(comfyPage: ComfyPage) {
|
||||
@@ -278,4 +280,31 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
.getByRole('combobox', { name: 'scheduler', exact: true })
|
||||
await expect(schedulerComboAfterReload).toContainText('karras')
|
||||
})
|
||||
|
||||
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const nodeName = 'Resize Image/Mask'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 630 }
|
||||
})
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
|
||||
await node.select()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
|
||||
await combo.click()
|
||||
const dropdown = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.selectDefaultViewport
|
||||
)
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
|
||||
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
|
||||
const cX = bounds.x + bounds.width / 2
|
||||
const cY = bounds.y + bounds.height / 2
|
||||
const dropdownBounds = (await dropdown.boundingBox())!
|
||||
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
|
||||
await dropdown.click({ position, trial: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
--workflow-tabs-height: 2.375rem;
|
||||
--comfy-input-bg: #222;
|
||||
--comfy-textarea-font-size: 10px;
|
||||
--input-text: #ddd;
|
||||
--descrip-text: #999;
|
||||
--drag-text: #ccc;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -50,7 +49,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<ArrangeButton v-if="showArrange" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
|
||||
<ConfigureSubgraph v-if="showSubgraphButtons" />
|
||||
@@ -49,6 +50,7 @@
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ArrangeButton from '@/components/graph/selectionToolbox/ArrangeButton.vue'
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
|
||||
@@ -110,6 +112,7 @@ const {
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
const showArrange = computed(() => hasMultipleSelection.value)
|
||||
const showFrameNodes = computed(() => hasMultipleSelection.value)
|
||||
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
@@ -128,6 +131,7 @@ const showAnyPrimaryActions = computed(
|
||||
() =>
|
||||
showColorPicker.value ||
|
||||
showConvertToSubgraph.value ||
|
||||
showArrange.value ||
|
||||
showFrameNodes.value ||
|
||||
showSubgraphButtons.value
|
||||
)
|
||||
|
||||
115
src/components/graph/selectionToolbox/ArrangeButton.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrange'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrange')"
|
||||
>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i class="icon-[lucide--layout-grid]" />
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<div
|
||||
v-if="activeLayout"
|
||||
class="flex w-32 flex-row items-center px-2 py-1"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[gap]"
|
||||
:min="MIN_ARRANGE_GAP"
|
||||
:max="MAX_ARRANGE_GAP"
|
||||
:step="1"
|
||||
:aria-label="t('g.arrangeSpacing')"
|
||||
@update:model-value="onSliderUpdate"
|
||||
@value-commit="onSliderCommit"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-row gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeVertically'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeVertically')"
|
||||
@click="start('vertical')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-horizontal]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeHorizontally'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeHorizontally')"
|
||||
@click="start('horizontal')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-vertical]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrangeAsGrid'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeAsGrid')"
|
||||
@click="start('grid')"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3]" />
|
||||
</Button>
|
||||
</div>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
MAX_ARRANGE_GAP,
|
||||
MIN_ARRANGE_GAP
|
||||
} from '@/composables/graph/useArrangeNodes'
|
||||
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { activeLayout, gap, start, previewGap, commitGap, reset } =
|
||||
useArrangeSession()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) reset()
|
||||
})
|
||||
|
||||
const firstValue = (value: number[] | undefined): number | undefined =>
|
||||
value?.[0]
|
||||
|
||||
const onSliderUpdate = (value: number[] | undefined) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) previewGap(next)
|
||||
}
|
||||
|
||||
const onSliderCommit = (value: number[]) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) commitGap(next)
|
||||
}
|
||||
</script>
|
||||
@@ -2,20 +2,12 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
/**
|
||||
* ErrorNodeCard displays a single error card inside the error tab.
|
||||
* It shows the node header (ID badge, title, action buttons)
|
||||
* and the list of error items (message, traceback, copy button).
|
||||
*/
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
component: ErrorNodeCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
showNodeIdBadge: { control: 'boolean' }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
@@ -105,58 +97,36 @@ const promptOnlyCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
/** Single validation error with node ID badge visible */
|
||||
export const WithNodeIdBadge: Story = {
|
||||
export const SingleValidationError: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Single validation error without node ID badge */
|
||||
export const WithoutNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: false
|
||||
card: singleErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Regular node error — no "Enter subgraph" button */
|
||||
export const WithoutEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
card: subgraphErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
card: multipleErrorsCard,
|
||||
showNodeIdBadge: true
|
||||
card: multipleErrorsCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime execution error with full traceback */
|
||||
export const RuntimeError: Story = {
|
||||
args: {
|
||||
card: runtimeErrorCard,
|
||||
showNodeIdBadge: true
|
||||
card: runtimeErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-level error (no node header) */
|
||||
export const PromptError: Story = {
|
||||
args: {
|
||||
card: promptOnlyCard,
|
||||
showNodeIdBadge: false
|
||||
card: promptOnlyCard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
en: {
|
||||
g: {
|
||||
copy: 'Copy',
|
||||
details: 'Details',
|
||||
findIssues: 'Find Issues',
|
||||
findOnGithub: 'Find on GitHub',
|
||||
getHelpAction: 'Get Help'
|
||||
@@ -78,6 +79,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
'Report this error and we\u0027ll help you resolve it'
|
||||
@@ -96,8 +98,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onCopyToClipboard = vi.fn()
|
||||
const onLocateNode = vi.fn()
|
||||
render(ErrorNodeCard, {
|
||||
props: { card, onCopyToClipboard },
|
||||
props: { card, onCopyToClipboard, onLocateNode },
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
@@ -131,14 +134,20 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
Button: {
|
||||
template:
|
||||
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, onCopyToClipboard }
|
||||
return { user, onCopyToClipboard, onLocateNode }
|
||||
}
|
||||
|
||||
async function toggleRuntimeDetails(
|
||||
user: ReturnType<typeof userEvent.setup>
|
||||
) {
|
||||
await user.click(screen.getByRole('button', { name: /Details/ }))
|
||||
}
|
||||
|
||||
let cardIdCounter = 0
|
||||
@@ -160,40 +169,67 @@ describe('ErrorNodeCard.vue', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function makeValidationErrorCard(): ErrorCardData {
|
||||
function makePromptErrorCard(): ErrorCardData {
|
||||
return {
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '6',
|
||||
nodeTitle: 'CLIP Text Encode',
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs',
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: text'
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details',
|
||||
displayMessage:
|
||||
'The workflow does not contain any output nodes to produce a result.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it('displays enriched report for runtime errors on mount', async () => {
|
||||
it('shows runtime details by default and can collapse them', async () => {
|
||||
const reportText =
|
||||
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
|
||||
mockGenerateErrorReport.mockReturnValue(reportText)
|
||||
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Error log')).toBeInTheDocument()
|
||||
const detailsButton = screen.getByRole('button', { name: /Details/ })
|
||||
const detailsRegion = screen.getByRole('region', { name: 'Error log' })
|
||||
expect(detailsButton).toHaveAttribute(
|
||||
'aria-controls',
|
||||
detailsRegion.getAttribute('id')
|
||||
)
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/System Information/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Find on GitHub/ })
|
||||
).toBeInTheDocument()
|
||||
|
||||
await toggleRuntimeDetails(user)
|
||||
|
||||
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Find on GitHub/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locates the node when the runtime node title is clicked', async () => {
|
||||
const { user, onLocateNode } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'KSampler' }))
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not generate report for non-runtime errors', async () => {
|
||||
renderCard(makeValidationErrorCard())
|
||||
renderCard(makePromptErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockGetLogs).not.toHaveBeenCalled()
|
||||
@@ -201,15 +237,15 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
it('displays original details for non-runtime errors', async () => {
|
||||
renderCard(makeValidationErrorCard())
|
||||
renderCard(makePromptErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error details')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays catalog-resolved copy when available', async () => {
|
||||
it('hides grouped catalog copy and shows the item label as a list item', async () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
@@ -229,17 +265,17 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
|
||||
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Required input slots have no connection feeding them.')
|
||||
).toBeInTheDocument()
|
||||
screen.queryByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('Required input is missing')
|
||||
screen.queryByText('KSampler is missing a required input: model')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -250,8 +286,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
@@ -261,21 +298,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('copies original details when copy button is clicked for validation error', async () => {
|
||||
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
|
||||
'Required input is missing\n\nInput: text'
|
||||
)
|
||||
})
|
||||
|
||||
it('generates report with fallback logs when getLogs fails', async () => {
|
||||
mockGetLogs.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
@@ -300,8 +322,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens GitHub issues search when Find Issue button is clicked', async () => {
|
||||
@@ -310,9 +333,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Find on GitHub/ })
|
||||
).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
|
||||
@@ -335,9 +356,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Get Help/ })
|
||||
).toBeInTheDocument()
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Get Help/ }))
|
||||
@@ -398,9 +417,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
|
||||
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-if="card.nodeTitle || card.title"
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
@@ -27,6 +28,24 @@
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasRuntimeError"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="t('g.details')"
|
||||
:aria-controls="runtimeDetailsControlIds || undefined"
|
||||
:aria-expanded="runtimeDetailsExpanded"
|
||||
@click.stop="toggleRuntimeDetails"
|
||||
>
|
||||
<i class="icon-[lucide--monitor-x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
@@ -39,120 +58,143 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-col gap-3',
|
||||
fullHeight && error.isRuntimeError && 'flex-1'
|
||||
)
|
||||
"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<!-- Human-friendly category/title when resolved by the error catalog. -->
|
||||
<p
|
||||
v-if="error.displayTitle"
|
||||
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
|
||||
>
|
||||
{{ error.displayTitle }}
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="getDisplayMessage(error)"
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getDisplayMessage(error) }}
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details (enriched with full report for runtime errors) -->
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
</button>
|
||||
<span v-else>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="displayedDetailsMap[idx]"
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
|
||||
error.isRuntimeError
|
||||
? fullHeight
|
||||
? 'min-h-0 flex-1'
|
||||
: 'max-h-[15lh]'
|
||||
: 'max-h-[6lh]'
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ displayedDetailsMap[idx] }}
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
{{ t('g.findOnGithub') }}
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
|
||||
@click="handleGetHelp"
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
|
||||
:id="getRuntimeDetailsId(idx)"
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
{{ t('g.getHelpAction') }}
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false,
|
||||
fullHeight = false
|
||||
} = defineProps<{
|
||||
const { card, compact = false } = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
/** Allow runtime error details to fill available height (used in dedicated panel) */
|
||||
fullHeight?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -164,6 +206,23 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const { displayedDetailsMap } = useErrorReport(() => card)
|
||||
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
|
||||
const runtimeDetailsExpanded = ref(true)
|
||||
const hasRuntimeError = computed(() =>
|
||||
card.errors.some((error) => error.isRuntimeError)
|
||||
)
|
||||
const isRuntimeDisclosureExpanded = computed(
|
||||
() => compact || runtimeDetailsExpanded.value
|
||||
)
|
||||
const runtimeDetailsControlIds = computed(() =>
|
||||
card.errors
|
||||
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
function toggleRuntimeDetails() {
|
||||
runtimeDetailsExpanded.value = !runtimeDetailsExpanded.value
|
||||
}
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
@@ -179,7 +238,7 @@ function handleEnterSubgraph() {
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = getDisplayMessage(card.errors[idx])
|
||||
const message = getCopyMessage(card.errors[idx])
|
||||
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
|
||||
}
|
||||
|
||||
@@ -187,7 +246,26 @@ function handleCheckGithub(error: ErrorItem) {
|
||||
findOnGitHub(error.message)
|
||||
}
|
||||
|
||||
function getDisplayMessage(error: ErrorItem | undefined) {
|
||||
function getCopyMessage(error: ErrorItem | undefined) {
|
||||
return error?.displayMessage ?? error?.message
|
||||
}
|
||||
|
||||
function getInlineMessage(error: ErrorItem | undefined) {
|
||||
if (!error || error.displayMessage) return undefined
|
||||
return error.message
|
||||
}
|
||||
|
||||
function getInlineItemLabel(error: ErrorItem | undefined) {
|
||||
if (!error || error.isRuntimeError) return undefined
|
||||
return error.displayItemLabel
|
||||
}
|
||||
|
||||
function getInlineDetails(error: ErrorItem | undefined, idx: number) {
|
||||
if (getInlineItemLabel(error)) return undefined
|
||||
return displayedDetailsMap.value[idx]
|
||||
}
|
||||
|
||||
function getRuntimeDetailsId(idx: number) {
|
||||
return `${card.id}-runtime-details-${idx}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const mockFocusNode = vi.hoisted(() => vi.fn())
|
||||
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
@@ -38,6 +41,13 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
|
||||
downloadModel: vi.fn(),
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
@@ -52,6 +62,7 @@ describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -59,11 +70,22 @@ describe('TabErrors.vue', () => {
|
||||
en: {
|
||||
g: {
|
||||
workflow: 'Workflow',
|
||||
copy: 'Copy'
|
||||
copy: 'Copy',
|
||||
details: 'Details',
|
||||
findOnGithub: 'Find on GitHub',
|
||||
getHelpAction: 'Get Help'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
getHelpTooltip: 'Get help',
|
||||
info: 'Info',
|
||||
infoFor: 'Info for {item}',
|
||||
locateNode: 'Locate node',
|
||||
locateNodeFor: 'Locate {item}',
|
||||
missingModels: {
|
||||
missingModelsTitle: 'Missing Models',
|
||||
downloadAll: 'Download all',
|
||||
@@ -144,29 +166,111 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by class_type', async () => {
|
||||
it('renders node validation errors grouped by catalog copy', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'CLIP Text Encode'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
const titles: Record<string, string> = {
|
||||
'1': 'KSampler',
|
||||
'2': 'CLIP Text Encode'
|
||||
}
|
||||
return {
|
||||
title: titles[String(nodeId)] ?? ''
|
||||
} as ReturnType<typeof getNodeByExecutionId>
|
||||
})
|
||||
|
||||
renderComponent({
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
'2': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{ message: 'Required input is missing', details: 'Input: text' }
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: clip',
|
||||
extra_info: {
|
||||
input_name: 'clip'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: positive',
|
||||
extra_info: {
|
||||
input_name: 'positive'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: model',
|
||||
extra_info: {
|
||||
input_name: 'model'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
|
||||
expect(screen.getByText('#6')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
|
||||
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(screen.queryByText('#1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('#2')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('CLIP Text Encode')).not.toBeInTheDocument()
|
||||
|
||||
const itemRows = screen.getAllByRole('listitem')
|
||||
expect(itemRows).toHaveLength(3)
|
||||
expect(itemRows[0]).toHaveTextContent('KSampler - model')
|
||||
expect(itemRows[1]).toHaveTextContent('KSampler - positive')
|
||||
expect(itemRows[2]).toHaveTextContent('CLIP Text Encode - clip')
|
||||
|
||||
const infoButton = within(itemRows[1]).getByRole('button', {
|
||||
name: 'Info for KSampler - positive'
|
||||
})
|
||||
|
||||
await user.click(infoButton)
|
||||
|
||||
const itemDetail = screen.getByText(
|
||||
'KSampler is missing a required input: positive'
|
||||
)
|
||||
expect(infoButton).toHaveAttribute(
|
||||
'aria-controls',
|
||||
itemDetail.getAttribute('id')
|
||||
)
|
||||
|
||||
const labelLocateButton = within(itemRows[1]).getByRole('button', {
|
||||
name: 'KSampler - positive'
|
||||
})
|
||||
|
||||
await user.click(labelLocateButton)
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('1')
|
||||
|
||||
const iconLocateButton = within(itemRows[2]).getByRole('button', {
|
||||
name: 'Locate CLIP Text Encode - clip'
|
||||
})
|
||||
|
||||
await user.click(iconLocateButton)
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('2')
|
||||
|
||||
expect(
|
||||
screen.queryByText('Required input is missing')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: model')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: positive')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: clip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders runtime execution errors from WebSocket', async () => {
|
||||
@@ -175,7 +279,7 @@ describe('TabErrors.vue', () => {
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
renderComponent({
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
@@ -190,12 +294,16 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Node threw an error during execution.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Error log')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Details' }))
|
||||
|
||||
expect(screen.queryByText(/Line 1/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters errors based on search query', async () => {
|
||||
@@ -230,7 +338,7 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls copyToClipboard when copy button is clicked', async () => {
|
||||
it('calls copyToClipboard when a runtime error copy button is clicked', async () => {
|
||||
const { useCopyToClipboard } =
|
||||
await import('@/composables/useCopyToClipboard')
|
||||
const mockCopy = vi.fn()
|
||||
@@ -238,21 +346,26 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
errors: [{ message: 'Test message', details: 'Test details' }]
|
||||
}
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '1',
|
||||
node_type: 'TestNode',
|
||||
exception_message: 'Test message',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['Test details'],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('error-card-copy'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
expect(mockCopy).toHaveBeenCalledWith(
|
||||
'Node threw an error during execution.\n\nTest details'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders single runtime error outside accordion in full-height panel', async () => {
|
||||
it('renders a single runtime error in the normal execution group', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'KSampler'
|
||||
@@ -274,7 +387,11 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByTestId(
|
||||
'runtime-error-panel'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -11,32 +11,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Runtime error: full-height panel outside accordion -->
|
||||
<div
|
||||
v-if="singleRuntimeErrorCard"
|
||||
data-testid="runtime-error-panel"
|
||||
aria-live="polite"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
|
||||
>
|
||||
{{ singleRuntimeErrorGroup?.displayTitle }}
|
||||
</div>
|
||||
<ErrorNodeCard
|
||||
:key="singleRuntimeErrorCard.id"
|
||||
:card="singleRuntimeErrorCard"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
full-height
|
||||
class="min-h-0 flex-1"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content (non-runtime or mixed errors) -->
|
||||
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
@@ -70,10 +45,13 @@
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.type === 'execution' && group.cards.length > 1"
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
@@ -155,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.type !== 'execution' && group.displayMessage"
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
@@ -186,12 +164,79 @@
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@@ -255,6 +300,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
@@ -266,6 +312,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
@@ -285,6 +332,13 @@ import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
key: string
|
||||
nodeId: string
|
||||
label: string
|
||||
displayDetails?: string
|
||||
}
|
||||
|
||||
const ErrorPanelSurveyCta =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -307,6 +361,7 @@ const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
@@ -325,6 +380,78 @@ const showNodeIdBadge = computed(
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
group.cards.length > 0 &&
|
||||
group.cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
const items: ExecutionItemListEntry[] = []
|
||||
for (const card of group.cards) {
|
||||
if (!card.nodeId) continue
|
||||
for (let idx = 0; idx < card.errors.length; idx++) {
|
||||
const error = card.errors[idx]
|
||||
const label = error.displayItemLabel
|
||||
if (!label) continue
|
||||
items.push({
|
||||
key: `${card.id}:${idx}`,
|
||||
nodeId: card.nodeId,
|
||||
label,
|
||||
displayDetails: error.displayDetails
|
||||
})
|
||||
}
|
||||
}
|
||||
return items.sort(compareExecutionItemListEntry)
|
||||
}
|
||||
|
||||
function compareExecutionItemListEntry(
|
||||
a: ExecutionItemListEntry,
|
||||
b: ExecutionItemListEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionGroupCount(group: ErrorGroup) {
|
||||
if (group.type !== 'execution') return 0
|
||||
if (isExecutionItemListGroup(group)) {
|
||||
return group.cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
return group.cards.length
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
|
||||
function toggleExecutionItemDetail(key: string) {
|
||||
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key)
|
||||
} else {
|
||||
nextKeys.add(key)
|
||||
}
|
||||
expandedExecutionItemDetailKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function getExecutionItemDetailId(key: string) {
|
||||
return `execution-item-detail-${key}`
|
||||
}
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
@@ -356,20 +483,6 @@ function handleMissingModelRefresh() {
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const singleRuntimeErrorGroup = computed(() => {
|
||||
if (filteredGroups.value.length !== 1) return null
|
||||
const group = filteredGroups.value[0]
|
||||
const isSoleRuntimeError =
|
||||
group.type === 'execution' &&
|
||||
group.cards.length === 1 &&
|
||||
group.cards[0].errors.every((e) => e.isRuntimeError)
|
||||
return isSoleRuntimeError ? group : null
|
||||
})
|
||||
|
||||
const singleRuntimeErrorCard = computed(
|
||||
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
||||
)
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
||||
|
||||
@@ -23,6 +23,9 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
const unknownValidationMessage = vi.hoisted(
|
||||
() => 'A node returned a validation error ComfyUI does not recognize.'
|
||||
)
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
@@ -43,6 +46,18 @@ vi.mock('@/i18n', () => {
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.title':
|
||||
'Validation failed',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.message':
|
||||
unknownValidationMessage,
|
||||
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
|
||||
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
|
||||
'{nodeName}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
|
||||
'Validation failed',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
|
||||
'{nodeName} returned an unrecognized validation error.',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
@@ -384,7 +399,7 @@ describe('useErrorGroups', () => {
|
||||
expect(swapIdx).toBeLessThan(missingIdx)
|
||||
})
|
||||
|
||||
it('includes execution error groups from node errors', async () => {
|
||||
it('uses fallback catalog grouping for unknown node validation errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
@@ -405,8 +420,8 @@ describe('useErrorGroups', () => {
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
expect(execGroups[0].groupKey).toBe('execution:KSampler')
|
||||
expect(execGroups[0].displayTitle).toBe('KSampler')
|
||||
expect(execGroups[0].groupKey).toBe('execution:unknown_validation_error')
|
||||
expect(execGroups[0].displayTitle).toBe('Validation failed')
|
||||
})
|
||||
|
||||
it('resolves required_input_missing item display copy', async () => {
|
||||
@@ -455,6 +470,55 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'model',
|
||||
extra_info: {
|
||||
input_name: 'model'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'clip',
|
||||
extra_info: {
|
||||
input_name: 'clip'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroups = groups.allErrorGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups).toHaveLength(1)
|
||||
|
||||
const [group] = execGroups
|
||||
expect(group.groupKey).toBe('execution:missing_connection')
|
||||
expect(group.displayTitle).toBe('Missing connection')
|
||||
expect(group.cards.map((card) => card.title)).toEqual([
|
||||
'KSampler',
|
||||
'CLIPLoader'
|
||||
])
|
||||
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { store, groups } = createErrorGroups()
|
||||
@@ -716,7 +780,7 @@ describe('useErrorGroups', () => {
|
||||
expect(groups.groupedErrorMessages.value).toEqual([])
|
||||
})
|
||||
|
||||
it('collects unique error messages from node errors', async () => {
|
||||
it('collects unique display messages from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
@@ -736,10 +800,7 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const messages = groups.groupedErrorMessages.value
|
||||
expect(messages).toContain('Error A')
|
||||
expect(messages).toContain('Error B')
|
||||
// Deduplication: Error A appears twice but should only be listed once
|
||||
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
|
||||
expect(messages).toEqual([unknownValidationMessage])
|
||||
})
|
||||
|
||||
it('includes missing node group display message', async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
@@ -66,6 +66,7 @@ export interface SwapNodeGroup {
|
||||
interface GroupEntry {
|
||||
type: 'execution'
|
||||
displayTitle: string
|
||||
displayMessage?: string
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
@@ -75,10 +76,14 @@ interface ErrorSearchItem {
|
||||
cardIndex: number
|
||||
searchableNodeId: string
|
||||
searchableNodeTitle: string
|
||||
searchableRawMessage: string
|
||||
searchableRawDetails: string
|
||||
searchableMessage: string
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
@@ -106,17 +111,21 @@ function getOrCreateGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
groupKey: string,
|
||||
displayTitle = groupKey,
|
||||
priority = 1
|
||||
priority = 1,
|
||||
displayMessage?: string
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(groupKey)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
type: 'execution',
|
||||
displayTitle,
|
||||
displayMessage,
|
||||
priority,
|
||||
cards: new Map()
|
||||
}
|
||||
groupsMap.set(groupKey, entry)
|
||||
} else if (!entry.displayMessage && displayMessage) {
|
||||
entry.displayMessage = displayMessage
|
||||
}
|
||||
return entry.cards
|
||||
}
|
||||
@@ -138,44 +147,6 @@ function createErrorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In single-node mode, regroup cards by error message instead of class_type.
|
||||
* This lets the user see "what kinds of errors this node has" at a glance.
|
||||
*/
|
||||
function regroupByErrorMessage(
|
||||
groupsMap: Map<string, GroupEntry>
|
||||
): Map<string, GroupEntry> {
|
||||
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
|
||||
Array.from(g.cards.values())
|
||||
)
|
||||
|
||||
const cardErrorPairs = allCards.flatMap((card) =>
|
||||
card.errors.map((error) => ({ card, error }))
|
||||
)
|
||||
|
||||
const messageMap = new Map<string, GroupEntry>()
|
||||
for (const { card, error } of cardErrorPairs) {
|
||||
addCardErrorToGroup(messageMap, card, error)
|
||||
}
|
||||
|
||||
return messageMap
|
||||
}
|
||||
|
||||
function addCardErrorToGroup(
|
||||
messageMap: Map<string, GroupEntry>,
|
||||
card: ErrorCardData,
|
||||
error: ErrorItem
|
||||
) {
|
||||
const displayTitle =
|
||||
error.displayTitle ?? error.displayMessage ?? error.message
|
||||
const groupKey = error.catalogId ?? displayTitle
|
||||
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
|
||||
if (!group.has(card.id)) {
|
||||
group.set(card.id, { ...card, errors: [] })
|
||||
}
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
||||
return compareExecutionId(a.nodeId, b.nodeId)
|
||||
}
|
||||
@@ -186,6 +157,7 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
@@ -209,6 +181,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableRawMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableRawDetails: card.errors.map((e) => e.details).join(' '),
|
||||
searchableMessage: card.errors
|
||||
.map((e) =>
|
||||
[e.displayTitle, e.displayMessage, e.message]
|
||||
@@ -225,9 +199,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
|
||||
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
|
||||
keys: [
|
||||
{ name: 'searchableNodeId', weight: 0.3 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.3 },
|
||||
{ name: 'searchableMessage', weight: 0.3 },
|
||||
{ name: 'searchableRawMessage', weight: 0.3 },
|
||||
{ name: 'searchableNodeId', weight: 0.2 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.2 },
|
||||
{ name: 'searchableMessage', weight: 0.2 },
|
||||
{ name: 'searchableRawDetails', weight: 0.1 },
|
||||
{ name: 'searchableDetails', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3
|
||||
@@ -333,18 +309,23 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
errors: ErrorItem[],
|
||||
error: CataloguedErrorItem,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
||||
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
|
||||
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
|
||||
const cards = getOrCreateGroup(
|
||||
groupsMap,
|
||||
error.catalogId,
|
||||
error.displayTitle ?? classType,
|
||||
1,
|
||||
error.displayMessage
|
||||
)
|
||||
if (!cards.has(nodeId)) {
|
||||
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push(...errors)
|
||||
card.errors.push(error)
|
||||
}
|
||||
|
||||
function processPromptError(
|
||||
@@ -368,7 +349,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap,
|
||||
`prompt:${error.type}`,
|
||||
groupDisplayTitle,
|
||||
0
|
||||
0,
|
||||
resolvedDisplay.displayMessage
|
||||
)
|
||||
|
||||
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
||||
@@ -395,13 +377,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
)) {
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
nodeError.errors.map((e) => {
|
||||
return {
|
||||
for (const e of nodeError.errors) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
{
|
||||
message: e.message,
|
||||
details: e.details ?? undefined,
|
||||
...resolveRunErrorMessage({
|
||||
@@ -409,10 +391,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
error: e,
|
||||
nodeDisplayName
|
||||
})
|
||||
}
|
||||
}),
|
||||
filterBySelection
|
||||
)
|
||||
},
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,20 +410,18 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
[
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
}
|
||||
],
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
@@ -867,10 +847,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
const executionGroups = isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
const filterByNode = selectedNodeInfo.value.nodeIds !== null
|
||||
|
||||
// Missing nodes are intentionally unfiltered — they represent
|
||||
@@ -883,7 +859,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...executionGroups
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
182
src/composables/graph/useArrangeNodes.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
interface MockNodeSpec {
|
||||
id: number | string
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
title_mode?: TitleMode
|
||||
}
|
||||
|
||||
const makeNode = (spec: MockNodeSpec): LGraphNode =>
|
||||
({
|
||||
id: spec.id,
|
||||
pos: spec.pos,
|
||||
size: spec.size,
|
||||
title_mode: spec.title_mode
|
||||
}) as unknown as LGraphNode
|
||||
|
||||
const GAP = 12
|
||||
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
|
||||
|
||||
describe('computeArrangement', () => {
|
||||
it('returns no updates when fewer than 2 nodes are selected', () => {
|
||||
expect(computeArrangement([], 'vertical')).toEqual([])
|
||||
expect(
|
||||
computeArrangement(
|
||||
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
|
||||
'grid'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
describe('vertical', () => {
|
||||
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
|
||||
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
|
||||
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
|
||||
]
|
||||
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
|
||||
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
|
||||
// Each node's pos.y = visualTop + its titleHeight (30).
|
||||
// b: pos.y = 70+30 = 100; visualTop += (30+30)+12 = 142
|
||||
// a: pos.y = 142+30 = 172; visualTop += (50+30)+12 = 234
|
||||
// c: pos.y = 234+30 = 264
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 10, y: 100 } },
|
||||
{ nodeId: 'a', position: { x: 10, y: 172 } },
|
||||
{ nodeId: 'c', position: { x: 10, y: 264 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the title-height contribution for NO_TITLE nodes', () => {
|
||||
const nodes = [
|
||||
makeNode({
|
||||
id: 1,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
}),
|
||||
makeNode({
|
||||
id: 2,
|
||||
pos: [0, 200],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
})
|
||||
]
|
||||
// No titles: visualHeight = size[1] = 100. visualTop = 0. Gap = 12.
|
||||
// 1: pos.y = 0; visualTop = 0 + 100 + 12 = 112.
|
||||
// 2: pos.y = 112.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves heterogeneous heights when computing gaps', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
|
||||
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
|
||||
]
|
||||
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
|
||||
// 2: pos.y = 212+30 = 242.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('horizontal', () => {
|
||||
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
|
||||
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
|
||||
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
|
||||
]
|
||||
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
|
||||
// Sort by X: b(0), a(100), c(300)
|
||||
// Lay out from (100, 50):
|
||||
// b at (100, 50)
|
||||
// a at (100 + 60 + 12, 50) = (172, 50)
|
||||
// c at (172 + 80 + 12, 50) = (264, 50)
|
||||
const result = computeArrangement(nodes, 'horizontal')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 100, y: 50 } },
|
||||
{ nodeId: 'a', position: { x: 172, y: 50 } },
|
||||
{ nodeId: 'c', position: { x: 264, y: 50 } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid', () => {
|
||||
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
|
||||
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
|
||||
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
|
||||
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
|
||||
]
|
||||
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
|
||||
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
|
||||
// Row visual heights: row0=max(50+30,60+30)=90; row1=max(40+30,30+30)=70.
|
||||
// colX=[0, 132]. rowVisualTop=[-30, -30+90+12=72].
|
||||
// pos.y = rowVisualTop + 30 (titleHeight).
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 132, y: 0 } },
|
||||
{ nodeId: 3, position: { x: 0, y: 102 } },
|
||||
{ nodeId: 4, position: { x: 132, y: 102 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
|
||||
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
|
||||
const nodes = Array.from({ length: 5 }, (_, i) =>
|
||||
makeNode({
|
||||
id: i + 1,
|
||||
pos: [i * 50, i * 50],
|
||||
size: [40, 40]
|
||||
})
|
||||
)
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toHaveLength(5)
|
||||
// Sorted by Y then X = original order. Anchor = node 1 at (0,0).
|
||||
// colWidths=[40,40,40]. rowVisualHeight = 40+30 = 70 each.
|
||||
// colX=[0,52,104]. rowVisualTop=[-30, -30+70+12=52]. pos.y = visualTop+30.
|
||||
expect(result[0].position).toEqual({ x: 0, y: 0 })
|
||||
expect(result[1].position).toEqual({ x: 52, y: 0 })
|
||||
expect(result[2].position).toEqual({ x: 104, y: 0 })
|
||||
expect(result[3].position).toEqual({ x: 0, y: 82 })
|
||||
expect(result[4].position).toEqual({ x: 52, y: 82 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('anchor selection', () => {
|
||||
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
|
||||
const nodes = [
|
||||
// min y but large x: x+y = 1000
|
||||
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
|
||||
// min x but large y: x+y = 1000
|
||||
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
|
||||
// smallest x+y: 600
|
||||
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
|
||||
]
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
|
||||
expect(result[0]).toEqual({
|
||||
nodeId: 'minY',
|
||||
position: { x: 300, y: 300 }
|
||||
})
|
||||
expect(result.every((u) => u.position.x === 300)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
186
src/composables/graph/useArrangeNodes.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'
|
||||
|
||||
export const DEFAULT_ARRANGE_GAP = 12
|
||||
export const MIN_ARRANGE_GAP = 0
|
||||
export const MAX_ARRANGE_GAP = 48
|
||||
|
||||
interface NodeBox {
|
||||
id: NodeId
|
||||
posX: number
|
||||
posY: number
|
||||
visualWidth: number
|
||||
visualHeight: number
|
||||
titleHeight: number
|
||||
}
|
||||
|
||||
interface ArrangeUpdate {
|
||||
nodeId: NodeId
|
||||
position: Point
|
||||
}
|
||||
|
||||
const titleHeightOf = (node: LGraphNode): number => {
|
||||
const mode = node.title_mode
|
||||
if (mode === TitleMode.TRANSPARENT_TITLE || mode === TitleMode.NO_TITLE) {
|
||||
return 0
|
||||
}
|
||||
return LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
const toBox = (node: LGraphNode): NodeBox => {
|
||||
const titleHeight = titleHeightOf(node)
|
||||
return {
|
||||
id: node.id,
|
||||
posX: node.pos[0],
|
||||
posY: node.pos[1],
|
||||
visualWidth: node.size[0],
|
||||
visualHeight: node.size[1] + titleHeight,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
|
||||
const byTopDown = (a: NodeBox, b: NodeBox) => a.posY - b.posY || a.posX - b.posX
|
||||
|
||||
const byLeftRight = (a: NodeBox, b: NodeBox) =>
|
||||
a.posX - b.posX || a.posY - b.posY
|
||||
|
||||
const findAnchor = (boxes: NodeBox[]): NodeBox =>
|
||||
boxes.reduce((best, box) =>
|
||||
box.posX + box.posY < best.posX + best.posY ? box : best
|
||||
)
|
||||
|
||||
const cumulativeOffsets = (
|
||||
sizes: number[],
|
||||
origin: number,
|
||||
gap: number
|
||||
): number[] => {
|
||||
const offsets: number[] = [origin]
|
||||
for (let i = 1; i < sizes.length; i++) {
|
||||
offsets.push(offsets[i - 1] + sizes[i - 1] + gap)
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
const arrangeVertical = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byTopDown)
|
||||
let visualTop = anchor.posY - anchor.titleHeight
|
||||
return sorted.map((box) => {
|
||||
const update: ArrangeUpdate = {
|
||||
nodeId: box.id,
|
||||
position: { x: anchor.posX, y: visualTop + box.titleHeight }
|
||||
}
|
||||
visualTop += box.visualHeight + gap
|
||||
return update
|
||||
})
|
||||
}
|
||||
|
||||
const arrangeHorizontal = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byLeftRight)
|
||||
const visualTop = anchor.posY - anchor.titleHeight
|
||||
let cursorX = anchor.posX
|
||||
return sorted.map((box) => {
|
||||
const update: ArrangeUpdate = {
|
||||
nodeId: box.id,
|
||||
position: { x: cursorX, y: visualTop + box.titleHeight }
|
||||
}
|
||||
cursorX += box.visualWidth + gap
|
||||
return update
|
||||
})
|
||||
}
|
||||
|
||||
const arrangeGrid = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byTopDown)
|
||||
const cols = Math.ceil(Math.sqrt(sorted.length))
|
||||
const rows = Math.ceil(sorted.length / cols)
|
||||
|
||||
const colWidths = new Array<number>(cols).fill(0)
|
||||
const rowHeights = new Array<number>(rows).fill(0)
|
||||
sorted.forEach((box, i) => {
|
||||
const col = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
if (box.visualWidth > colWidths[col]) colWidths[col] = box.visualWidth
|
||||
if (box.visualHeight > rowHeights[row]) rowHeights[row] = box.visualHeight
|
||||
})
|
||||
|
||||
const colX = cumulativeOffsets(colWidths, anchor.posX, gap)
|
||||
const rowVisualTop = cumulativeOffsets(
|
||||
rowHeights,
|
||||
anchor.posY - anchor.titleHeight,
|
||||
gap
|
||||
)
|
||||
|
||||
return sorted.map((box, i) => {
|
||||
const col = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
return {
|
||||
nodeId: box.id,
|
||||
position: {
|
||||
x: colX[col],
|
||||
y: rowVisualTop[row] + box.titleHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function computeArrangement(
|
||||
nodes: LGraphNode[],
|
||||
layout: ArrangeLayout,
|
||||
gap: number = DEFAULT_ARRANGE_GAP
|
||||
): ArrangeUpdate[] {
|
||||
if (nodes.length < 2) return []
|
||||
const boxes = nodes.map(toBox)
|
||||
const anchor = findAnchor(boxes)
|
||||
if (layout === 'vertical') return arrangeVertical(boxes, anchor, gap)
|
||||
if (layout === 'horizontal') return arrangeHorizontal(boxes, anchor, gap)
|
||||
return arrangeGrid(boxes, anchor, gap)
|
||||
}
|
||||
|
||||
interface ArrangeOptions {
|
||||
gap?: number
|
||||
captureUndo?: boolean
|
||||
}
|
||||
|
||||
export function useArrangeNodes() {
|
||||
const { selectedNodes, hasMultipleSelection } = useSelectionState()
|
||||
const mutations = useLayoutMutations()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const arrangeNodes = (
|
||||
layout: ArrangeLayout,
|
||||
{ gap = DEFAULT_ARRANGE_GAP, captureUndo = true }: ArrangeOptions = {}
|
||||
) => {
|
||||
if (!hasMultipleSelection.value) return
|
||||
const updates = computeArrangement(selectedNodes.value, layout, gap)
|
||||
if (updates.length === 0) return
|
||||
|
||||
mutations.setSource(LayoutSource.Canvas)
|
||||
mutations.batchMoveNodes(updates)
|
||||
app.canvas?.setDirty(true, true)
|
||||
if (captureUndo) {
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
}
|
||||
|
||||
return { arrangeNodes, canArrange: hasMultipleSelection }
|
||||
}
|
||||
115
src/composables/graph/useArrangeSession.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as ArrangeNodesModule from '@/composables/graph/useArrangeNodes'
|
||||
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
|
||||
|
||||
const mockArrangeNodes = vi.fn()
|
||||
|
||||
vi.mock('@/composables/graph/useArrangeNodes', async () => {
|
||||
const actual = await vi.importActual<typeof ArrangeNodesModule>(
|
||||
'@/composables/graph/useArrangeNodes'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useArrangeNodes: () => ({ arrangeNodes: mockArrangeNodes })
|
||||
}
|
||||
})
|
||||
|
||||
describe('useArrangeSession', () => {
|
||||
let frameCallbacks: Array<FrameRequestCallback>
|
||||
let nextHandle: number
|
||||
|
||||
beforeEach(() => {
|
||||
mockArrangeNodes.mockReset()
|
||||
frameCallbacks = []
|
||||
nextHandle = 1
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(
|
||||
(cb: FrameRequestCallback) => {
|
||||
frameCallbacks.push(cb)
|
||||
return nextHandle++
|
||||
}
|
||||
)
|
||||
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {
|
||||
frameCallbacks[id - 1] = () => {}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const flushFrames = () => {
|
||||
const callbacks = frameCallbacks
|
||||
frameCallbacks = []
|
||||
callbacks.forEach((cb) => cb(performance.now()))
|
||||
}
|
||||
|
||||
it('start() applies layout immediately and captures undo', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('vertical')
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('vertical', {
|
||||
gap: 12,
|
||||
captureUndo: true
|
||||
})
|
||||
expect(session.activeLayout.value).toBe('vertical')
|
||||
expect(session.gap.value).toBe(12)
|
||||
})
|
||||
|
||||
it('previewGap() throttles repeated calls into a single frame', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('grid')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(20)
|
||||
session.previewGap(30)
|
||||
session.previewGap(40)
|
||||
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('grid', {
|
||||
gap: 40,
|
||||
captureUndo: false
|
||||
})
|
||||
})
|
||||
|
||||
it('previewGap() is a no-op outside an active session', () => {
|
||||
const session = useArrangeSession()
|
||||
session.previewGap(20)
|
||||
flushFrames()
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commitGap() cancels any pending preview frame', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('horizontal')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(25)
|
||||
session.commitGap(36)
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('horizontal', {
|
||||
gap: 36,
|
||||
captureUndo: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reset() ends the session and prevents pending frames from arranging', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('vertical')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(40)
|
||||
session.reset()
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
expect(session.activeLayout.value).toBeNull()
|
||||
expect(session.gap.value).toBe(12)
|
||||
})
|
||||
})
|
||||
60
src/composables/graph/useArrangeSession.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
import {
|
||||
DEFAULT_ARRANGE_GAP,
|
||||
useArrangeNodes
|
||||
} from '@/composables/graph/useArrangeNodes'
|
||||
import type { ArrangeLayout } from '@/composables/graph/useArrangeNodes'
|
||||
|
||||
export function useArrangeSession() {
|
||||
const { arrangeNodes } = useArrangeNodes()
|
||||
|
||||
const activeLayout = ref<ArrangeLayout | null>(null)
|
||||
const gap = ref(DEFAULT_ARRANGE_GAP)
|
||||
let pendingFrame: number | null = null
|
||||
|
||||
const cancelPendingFrame = () => {
|
||||
if (pendingFrame === null) return
|
||||
cancelAnimationFrame(pendingFrame)
|
||||
pendingFrame = null
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
cancelPendingFrame()
|
||||
activeLayout.value = null
|
||||
gap.value = DEFAULT_ARRANGE_GAP
|
||||
}
|
||||
|
||||
const start = (layout: ArrangeLayout) => {
|
||||
gap.value = DEFAULT_ARRANGE_GAP
|
||||
activeLayout.value = layout
|
||||
arrangeNodes(layout, { gap: gap.value, captureUndo: true })
|
||||
}
|
||||
|
||||
const previewGap = (nextGap: number) => {
|
||||
if (activeLayout.value === null) return
|
||||
gap.value = nextGap
|
||||
cancelPendingFrame()
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null
|
||||
if (activeLayout.value === null) return
|
||||
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: false })
|
||||
})
|
||||
}
|
||||
|
||||
const commitGap = (nextGap: number) => {
|
||||
if (activeLayout.value === null) return
|
||||
cancelPendingFrame()
|
||||
gap.value = nextGap
|
||||
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: true })
|
||||
}
|
||||
|
||||
return {
|
||||
activeLayout: readonly(activeLayout),
|
||||
gap: readonly(gap),
|
||||
start,
|
||||
previewGap,
|
||||
commitGap,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,14 @@ import { nextTick, reactive, ref, shallowRef } from 'vue'
|
||||
import type { Pinia } from 'pinia'
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import {
|
||||
getLoad3dOutputCache,
|
||||
isLoad3dSceneDirty,
|
||||
markLoad3dSceneDirty,
|
||||
nodeToLoad3dMap,
|
||||
setLoad3dOutputCache,
|
||||
useLoad3d
|
||||
} from '@/composables/useLoad3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
@@ -186,6 +193,7 @@ describe('useLoad3d', () => {
|
||||
resetGizmoTransform: vi.fn(),
|
||||
applyGizmoTransform: vi.fn(),
|
||||
fitToViewer: vi.fn(),
|
||||
centerCameraOnModel: vi.fn(),
|
||||
getGizmoTransform: vi.fn().mockReturnValue({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
@@ -1742,4 +1750,184 @@ describe('useLoad3d', () => {
|
||||
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scene dirty tracking', () => {
|
||||
const fakeCache = {
|
||||
image: 'threed/scene-1.png [temp]',
|
||||
mask: 'threed/scene_mask-1.png [temp]',
|
||||
normal: 'threed/scene_normal-1.png [temp]',
|
||||
camera_info: null,
|
||||
recording: '',
|
||||
model_3d_info: []
|
||||
}
|
||||
|
||||
it('treats an unseen node as dirty by default', () => {
|
||||
const fresh = createMockLGraphNode({ properties: {} })
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(true)
|
||||
})
|
||||
|
||||
it('markLoad3dSceneDirty sets the node dirty', () => {
|
||||
const fresh = createMockLGraphNode({ properties: {} })
|
||||
setLoad3dOutputCache(fresh, fakeCache)
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(false)
|
||||
|
||||
markLoad3dSceneDirty(fresh)
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(true)
|
||||
})
|
||||
|
||||
it('setLoad3dOutputCache stores the output and clears dirty', () => {
|
||||
const fresh = createMockLGraphNode({ properties: {} })
|
||||
setLoad3dOutputCache(fresh, fakeCache)
|
||||
|
||||
expect(getLoad3dOutputCache(fresh)).toBe(fakeCache)
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(false)
|
||||
})
|
||||
|
||||
it('two nodes keep independent dirty state', () => {
|
||||
const a = createMockLGraphNode({ properties: {} })
|
||||
const b = createMockLGraphNode({ properties: {} })
|
||||
|
||||
setLoad3dOutputCache(a, fakeCache)
|
||||
expect(isLoad3dSceneDirty(a)).toBe(false)
|
||||
expect(isLoad3dSceneDirty(b)).toBe(true)
|
||||
|
||||
markLoad3dSceneDirty(a)
|
||||
expect(isLoad3dSceneDirty(a)).toBe(true)
|
||||
expect(isLoad3dSceneDirty(b)).toBe(true)
|
||||
})
|
||||
|
||||
it('markLoad3dSceneDirty on null is a no-op', () => {
|
||||
expect(() => markLoad3dSceneDirty(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('sceneConfig changes flip the node dirty', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
composable.sceneConfig.value.backgroundColor = '#ffffff'
|
||||
await nextTick()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('cameraChanged event marks the node dirty', async () => {
|
||||
let cameraChangedHandler: ((state: unknown) => void) | undefined
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'cameraChanged') {
|
||||
cameraChangedHandler = handler as (state: unknown) => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleStopRecording marks dirty when a recording was produced', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
|
||||
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(5)
|
||||
composable.handleStopRecording()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleStopRecording leaves dirty alone when no recording was produced', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
|
||||
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(0)
|
||||
composable.handleStopRecording()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('handleClearRecording marks dirty', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
composable.handleClearRecording()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleStartRecording marks dirty so an in-progress recording forces a re-capture', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
await composable.handleStartRecording()
|
||||
|
||||
expect(mockLoad3d.startRecording).toHaveBeenCalledTimes(1)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleCenterCameraOnModel marks dirty', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
composable.handleCenterCameraOnModel()
|
||||
|
||||
expect(mockLoad3d.centerCameraOnModel).toHaveBeenCalledTimes(1)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleSeek marks dirty when the animation has a duration', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
const calls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
|
||||
const match = calls.find(([event]) => event === 'animationProgressChange')
|
||||
const animationProgressHandler = match![1] as (d: {
|
||||
progress: number
|
||||
currentTime: number
|
||||
duration: number
|
||||
}) => void
|
||||
|
||||
animationProgressHandler({ progress: 0, currentTime: 0, duration: 10 })
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
|
||||
composable.handleSeek(50)
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
Model3DInfo,
|
||||
ModelConfig,
|
||||
SceneConfig,
|
||||
UpDirection
|
||||
@@ -38,6 +39,38 @@ import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
type Load3dReadyCallback = (load3d: Load3d) => void
|
||||
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
|
||||
|
||||
export type Load3dCachedOutput = {
|
||||
image: string
|
||||
mask: string
|
||||
normal: string
|
||||
camera_info: CameraState | null
|
||||
recording: string
|
||||
model_3d_info: Model3DInfo
|
||||
}
|
||||
|
||||
const load3dSceneDirty = new WeakMap<LGraphNode, boolean>()
|
||||
const load3dOutputCache = new WeakMap<LGraphNode, Load3dCachedOutput>()
|
||||
|
||||
export const markLoad3dSceneDirty = (node: LGraphNode | null): void => {
|
||||
if (!node) return
|
||||
load3dSceneDirty.set(node, true)
|
||||
}
|
||||
|
||||
export const isLoad3dSceneDirty = (node: LGraphNode): boolean =>
|
||||
load3dSceneDirty.get(node) !== false
|
||||
|
||||
export const getLoad3dOutputCache = (
|
||||
node: LGraphNode
|
||||
): Load3dCachedOutput | undefined => load3dOutputCache.get(node)
|
||||
|
||||
export const setLoad3dOutputCache = (
|
||||
node: LGraphNode,
|
||||
output: Load3dCachedOutput
|
||||
): void => {
|
||||
load3dOutputCache.set(node, output)
|
||||
load3dSceneDirty.set(node, false)
|
||||
}
|
||||
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
const persistentReadyCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
|
||||
@@ -69,6 +102,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
let load3d: Load3d | null = null
|
||||
let isFirstModelLoad = true
|
||||
|
||||
const markDirty = () => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
if (rawNode) markLoad3dSceneDirty(rawNode as LGraphNode)
|
||||
}
|
||||
|
||||
const debouncedHandleResize = useDebounceFn(() => {
|
||||
load3d?.handleResize()
|
||||
}, 150)
|
||||
@@ -371,6 +409,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (n) {
|
||||
n.properties['Light Config'] = lightConfig.value
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const waitForLoad3d = (callback: Load3dReadyCallback) => {
|
||||
@@ -415,6 +454,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (nodeRef.value) {
|
||||
nodeRef.value.properties['Scene Config'] = newValue
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -455,6 +495,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (nodeRef.value) {
|
||||
nodeRef.value.properties['Model Config'] = newValue
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -488,6 +529,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -547,18 +589,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) {
|
||||
load3d.toggleAnimation(newValue)
|
||||
}
|
||||
markDirty()
|
||||
})
|
||||
|
||||
watch(selectedSpeed, (newValue) => {
|
||||
if (load3d && newValue) {
|
||||
load3d.setAnimationSpeed(newValue)
|
||||
}
|
||||
markDirty()
|
||||
})
|
||||
|
||||
watch(selectedAnimation, (newValue) => {
|
||||
if (load3d && newValue !== undefined) {
|
||||
load3d.updateSelectedAnimation(newValue)
|
||||
}
|
||||
markDirty()
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -573,6 +618,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) {
|
||||
await load3d.startRecording()
|
||||
isRecording.value = true
|
||||
markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +628,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isRecording.value = false
|
||||
recordingDuration.value = load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
if (hasRecording.value) markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +645,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
load3d.clearRecording()
|
||||
hasRecording.value = false
|
||||
recordingDuration.value = 0
|
||||
markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +653,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d && animationDuration.value > 0) {
|
||||
const time = (progress / 100) * animationDuration.value
|
||||
load3d.setAnimationTime(time)
|
||||
markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,6 +985,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
state: cameraState
|
||||
}
|
||||
}
|
||||
markLoad3dSceneDirty(node)
|
||||
}
|
||||
},
|
||||
gizmoTransformChange: (data: GizmoConfig) => {
|
||||
@@ -976,7 +1026,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
|
||||
const handleCenterCameraOnModel = () => {
|
||||
load3d?.centerCameraOnModel()
|
||||
if (!load3d) return
|
||||
load3d.centerCameraOnModel()
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
*/
|
||||
interface TransformCompatOverlayOptions {
|
||||
/**
|
||||
* Where to append the overlay. 'self' keeps overlay within component
|
||||
* for proper transform inheritance, 'body' teleports to document body
|
||||
*/
|
||||
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
// Future: other props needed for transform compatibility
|
||||
// scrollTarget?: string | HTMLElement
|
||||
// autoZIndex?: boolean
|
||||
}
|
||||
|
||||
export const OverlayAppendToKey: InjectionKey<
|
||||
HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
> = Symbol('OverlayAppendTo')
|
||||
|
||||
/**
|
||||
* Composable that provides props to make PrimeVue overlay components
|
||||
* compatible with CSS-transformed parent elements.
|
||||
*
|
||||
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
|
||||
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
|
||||
* body by default, breaking transform inheritance. This composable provides
|
||||
* the necessary props to keep overlays within their component elements.
|
||||
*
|
||||
* @param overrides - Optional overrides for specific use cases
|
||||
* @returns Computed props object to spread on PrimeVue overlay components
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <template>
|
||||
* <Select v-bind="overlayProps" />
|
||||
* </template>
|
||||
*
|
||||
* <script setup>
|
||||
* const overlayProps = useTransformCompatOverlayProps()
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
|
||||
return computed(() => ({
|
||||
appendTo: injectedAppendTo ?? ('self' as const),
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
@@ -38,13 +38,27 @@ vi.mock('@/services/load3dService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap
|
||||
}))
|
||||
vi.mock('@/composables/useLoad3d', () => {
|
||||
const sceneDirty = new WeakMap<LGraphNode, boolean>()
|
||||
const outputCache = new WeakMap<LGraphNode, unknown>()
|
||||
return {
|
||||
useLoad3d: () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap,
|
||||
markLoad3dSceneDirty: (node: LGraphNode | null) => {
|
||||
if (!node) return
|
||||
sceneDirty.set(node, true)
|
||||
},
|
||||
isLoad3dSceneDirty: (node: LGraphNode) => sceneDirty.get(node) !== false,
|
||||
getLoad3dOutputCache: (node: LGraphNode) => outputCache.get(node),
|
||||
setLoad3dOutputCache: (node: LGraphNode, value: unknown) => {
|
||||
outputCache.set(node, value)
|
||||
sceneDirty.set(node, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
@@ -173,7 +187,7 @@ function makePreview3DAdvancedNode(
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
|
||||
widgets: overrides.widgets ?? [{ name: 'viewport_state', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
@@ -482,13 +496,16 @@ describe('Comfy.Load3D.nodeCreated', () => {
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'input',
|
||||
modelWidget: widgets[0],
|
||||
cameraState: undefined,
|
||||
width: widgets[1],
|
||||
height: widgets[2]
|
||||
})
|
||||
expect(configureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loadFolder: 'input',
|
||||
modelWidget: widgets[0],
|
||||
cameraState: undefined,
|
||||
width: widgets[1],
|
||||
height: widgets[2],
|
||||
onSceneInvalidated: expect.any(Function)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches a serializeValue function to the scene widget', async () => {
|
||||
@@ -783,9 +800,9 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches a camera-only serializeValue to the image widget', async () => {
|
||||
it('attaches a camera-only serializeValue to the viewport_state widget', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
@@ -795,7 +812,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
|
||||
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
@@ -819,7 +836,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
|
||||
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
@@ -1014,3 +1031,95 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D scene widget serializeValue caching', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
function makeFullFakeLoad3d() {
|
||||
return {
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
cameraManager: { perspectiveCamera: { fov: 35 } },
|
||||
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
|
||||
stopRecording: vi.fn(),
|
||||
captureScene: vi.fn(async () => ({
|
||||
scene: 'scene-data',
|
||||
mask: 'mask-data',
|
||||
normal: 'normal-data'
|
||||
})),
|
||||
handleResize: vi.fn(),
|
||||
getModelInfo: vi.fn(() => null),
|
||||
getRecordingData: vi.fn(() => null)
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const useLoad3dModule = await import('@/composables/useLoad3d')
|
||||
const utilsModule = await import('@/extensions/core/load3d/Load3dUtils')
|
||||
const uploadTempImage = utilsModule.default.uploadTempImage as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
let counter = 0
|
||||
uploadTempImage.mockImplementation(
|
||||
async (_data: unknown, kind: string) => ({
|
||||
name: `${kind}-${++counter}.png`
|
||||
})
|
||||
)
|
||||
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'm.glb' },
|
||||
{ name: 'width', value: 256 },
|
||||
{ name: 'height', value: 256 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets, properties: {} })
|
||||
useLoad3dModule.nodeToLoad3dMap.set(node, makeFullFakeLoad3d() as never)
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
const serialize = widgets[3].serializeValue! as () => Promise<{
|
||||
image: string
|
||||
} | null>
|
||||
|
||||
return { node, serialize, uploadTempImage, useLoad3dModule }
|
||||
}
|
||||
|
||||
it('reuses the cached output when the scene has not been dirtied', async () => {
|
||||
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
|
||||
|
||||
const first = await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(3)
|
||||
expect(first?.image).toBe('threed/scene-1.png [temp]')
|
||||
expect(useLoad3dModule.isLoad3dSceneDirty(node)).toBe(false)
|
||||
expect(useLoad3dModule.getLoad3dOutputCache(node)).toBe(first)
|
||||
|
||||
const second = await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(3)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('re-captures after the scene is marked dirty', async () => {
|
||||
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
|
||||
|
||||
await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(3)
|
||||
|
||||
useLoad3dModule.markLoad3dSceneDirty(node)
|
||||
|
||||
const refreshed = await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(6)
|
||||
expect(refreshed?.image).toBe('threed/scene-4.png [temp]')
|
||||
})
|
||||
|
||||
it('returns null when no load3d instance is registered for the node', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'm.glb' },
|
||||
{ name: 'width', value: 256 },
|
||||
{ name: 'height', value: 256 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
await load3DExt.nodeCreated(node)
|
||||
expect(await widgets[3].serializeValue!()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,15 @@ import { nextTick } from 'vue'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import {
|
||||
type Load3dCachedOutput,
|
||||
getLoad3dOutputCache,
|
||||
isLoad3dSceneDirty,
|
||||
markLoad3dSceneDirty,
|
||||
nodeToLoad3dMap,
|
||||
setLoad3dOutputCache,
|
||||
useLoad3d
|
||||
} from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import type {
|
||||
CameraConfig,
|
||||
@@ -96,6 +104,8 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
|
||||
|
||||
modelWidget.value = uploadPath
|
||||
}
|
||||
|
||||
markLoad3dSceneDirty(node)
|
||||
} catch (error) {
|
||||
console.error('Model upload failed:', error)
|
||||
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
|
||||
@@ -113,6 +123,7 @@ async function handleResourcesUpload(files: FileList, node: LGraphNode) {
|
||||
: '3d'
|
||||
|
||||
await Load3dUtils.uploadMultipleFiles(files, subfolder)
|
||||
markLoad3dSceneDirty(node)
|
||||
} catch (error) {
|
||||
console.error('Extra resources upload failed:', error)
|
||||
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
|
||||
@@ -270,8 +281,16 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
],
|
||||
getCustomWidgets() {
|
||||
const VIEWPORT_STATE_NODES = new Set([
|
||||
'Preview3DAdvanced',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
const inputName = VIEWPORT_STATE_NODES.has(node.constructor.comfyClass)
|
||||
? 'viewport_state'
|
||||
: 'image'
|
||||
const hasModelFileWidget = node.widgets?.some(
|
||||
(w) => w.name === 'model_file'
|
||||
)
|
||||
@@ -311,14 +330,15 @@ useExtensionService().registerExtension({
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
}
|
||||
markLoad3dSceneDirty(node)
|
||||
})
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node: node,
|
||||
name: 'image',
|
||||
name: inputName,
|
||||
component: Load3D,
|
||||
inputSpec: inputSpecLoad3D,
|
||||
inputSpec: { ...inputSpecLoad3D, name: inputName },
|
||||
options: {}
|
||||
})
|
||||
|
||||
@@ -369,7 +389,8 @@ useExtensionService().registerExtension({
|
||||
modelWidget,
|
||||
cameraState,
|
||||
width,
|
||||
height
|
||||
height,
|
||||
onSceneInvalidated: () => markLoad3dSceneDirty(node)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -387,6 +408,11 @@ useExtensionService().registerExtension({
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isLoad3dSceneDirty(node)) {
|
||||
const cached = getLoad3dOutputCache(node)
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
@@ -418,7 +444,7 @@ useExtensionService().registerExtension({
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
const returnVal = {
|
||||
const returnVal: Load3dCachedOutput = {
|
||||
image: `threed/${data.name} [temp]`,
|
||||
mask: `threed/${dataMask.name} [temp]`,
|
||||
normal: `threed/${dataNormal.name} [temp]`,
|
||||
@@ -435,9 +461,11 @@ useExtensionService().registerExtension({
|
||||
const [recording] = await Promise.all([
|
||||
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
|
||||
])
|
||||
returnVal['recording'] = `threed/${recording.name} [temp]`
|
||||
returnVal.recording = `threed/${recording.name} [temp]`
|
||||
}
|
||||
|
||||
setLoad3dOutputCache(node, returnVal)
|
||||
|
||||
return returnVal
|
||||
}
|
||||
}
|
||||
@@ -715,7 +743,7 @@ useExtensionService().registerExtension({
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
@@ -682,3 +682,138 @@ describe('Load3DConfiguration "none" model handling', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.onSceneInvalidated', () => {
|
||||
function makeLoad3dMock(): Load3d {
|
||||
return {
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
clearModel: vi.fn(),
|
||||
setUpDirection: vi.fn(),
|
||||
setMaterialMode: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIEnabled: vi.fn(),
|
||||
emitModelReady: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
|
||||
})
|
||||
|
||||
it('width.callback invokes onSceneInvalidated', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const width = { value: 1024 } as unknown as IBaseWidget
|
||||
const height = { value: 1024 } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget: { value: 'none' } as unknown as IBaseWidget,
|
||||
loadFolder: 'input',
|
||||
width,
|
||||
height,
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
width.callback!(2048)
|
||||
|
||||
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('height.callback invokes onSceneInvalidated', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const width = { value: 1024 } as unknown as IBaseWidget
|
||||
const height = { value: 1024 } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget: { value: 'none' } as unknown as IBaseWidget,
|
||||
loadFolder: 'input',
|
||||
width,
|
||||
height,
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
height.callback!(2048)
|
||||
|
||||
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('model_file widget callback invokes onSceneInvalidated after the model loads', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const modelWidget = { value: 'none' } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget,
|
||||
loadFolder: 'input',
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
modelWidget.value = 'model.glb'
|
||||
await flush()
|
||||
|
||||
expect(onSceneInvalidated).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves any pre-existing model widget callback alongside the invalidation hook', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const original = vi.fn()
|
||||
const modelWidget = {
|
||||
value: 'none',
|
||||
callback: original
|
||||
} as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget,
|
||||
loadFolder: 'input',
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
modelWidget.value = 'model.glb'
|
||||
await flush()
|
||||
|
||||
expect(original).toHaveBeenCalledWith('model.glb')
|
||||
expect(onSceneInvalidated).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('callbacks remain safe when onSceneInvalidated is omitted', async () => {
|
||||
const width = { value: 1024 } as unknown as IBaseWidget
|
||||
const height = { value: 1024 } as unknown as IBaseWidget
|
||||
const modelWidget = { value: 'none' } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget,
|
||||
loadFolder: 'input',
|
||||
width,
|
||||
height
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(() => width.callback!(2048)).not.toThrow()
|
||||
expect(() => height.callback!(2048)).not.toThrow()
|
||||
expect(() => {
|
||||
modelWidget.value = 'model.glb'
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,14 @@ type Load3DConfigurationSettings = {
|
||||
height?: IBaseWidget
|
||||
bgImagePath?: string
|
||||
silentOnNotFound?: boolean
|
||||
/**
|
||||
* Called when a user-driven change to one of the wired widgets
|
||||
* (model_file, width, height) makes the previously captured scene stale.
|
||||
* Backend caching covers these inputs by themselves; this hook lets the
|
||||
* caller invalidate any frontend-side capture cache so the next serialize
|
||||
* re-renders at the new state.
|
||||
*/
|
||||
onSceneInvalidated?: () => void
|
||||
}
|
||||
|
||||
const ANNOTATED_FILENAME_PATTERN = / \[(input|output|temp)\]$/
|
||||
@@ -63,22 +71,33 @@ class Load3DConfiguration {
|
||||
setting.modelWidget,
|
||||
setting.loadFolder,
|
||||
setting.cameraState,
|
||||
setting.silentOnNotFound ?? false
|
||||
setting.silentOnNotFound ?? false,
|
||||
setting.onSceneInvalidated
|
||||
)
|
||||
this.setupTargetSize(
|
||||
setting.width,
|
||||
setting.height,
|
||||
setting.onSceneInvalidated
|
||||
)
|
||||
this.setupTargetSize(setting.width, setting.height)
|
||||
this.setupDefaultProperties(setting.bgImagePath)
|
||||
}
|
||||
|
||||
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
|
||||
private setupTargetSize(
|
||||
width?: IBaseWidget,
|
||||
height?: IBaseWidget,
|
||||
onSceneInvalidated?: () => void
|
||||
) {
|
||||
if (width && height) {
|
||||
this.load3d.setTargetSize(width.value as number, height.value as number)
|
||||
|
||||
width.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(value, height.value as number)
|
||||
onSceneInvalidated?.()
|
||||
}
|
||||
|
||||
height.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(width.value as number, value)
|
||||
onSceneInvalidated?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +122,8 @@ class Load3DConfiguration {
|
||||
modelWidget: IBaseWidget,
|
||||
loadFolder: string,
|
||||
cameraState?: CameraState,
|
||||
silentOnNotFound: boolean = false
|
||||
silentOnNotFound: boolean = false,
|
||||
onSceneInvalidated?: () => void
|
||||
) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(
|
||||
loadFolder,
|
||||
@@ -137,6 +157,8 @@ class Load3DConfiguration {
|
||||
if (originalCallback) {
|
||||
originalCallback(value)
|
||||
}
|
||||
|
||||
onSceneInvalidated?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('scene.ply')
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'temp',
|
||||
'scene.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
@@ -231,7 +231,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
const node = makePreviewNode({
|
||||
widgets: [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'image', value: '' },
|
||||
{ name: 'viewport_state', value: '' },
|
||||
widthWidget,
|
||||
heightWidget
|
||||
]
|
||||
@@ -262,7 +262,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
)
|
||||
const sceneWidget: FakeWidget & {
|
||||
serializeValue?: () => Promise<unknown>
|
||||
} = { name: 'image', value: '' }
|
||||
} = { name: 'viewport_state', value: '' }
|
||||
const node = makePreviewNode({
|
||||
widgets: [{ name: 'model_file', value: '' }, sceneWidget]
|
||||
})
|
||||
@@ -318,7 +318,7 @@ describe('Comfy.PreviewPointCloud.nodeCreated', () => {
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('pointcloud.ply')
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'temp',
|
||||
'pointcloud.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ function applyResultToLoad3d(
|
||||
}
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', normalizedPath, {
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ function createPreview3DExtension(
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', lastTimeModelFile as string, {
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -136,7 +136,9 @@ function createPreview3DExtension(
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"title": "Title",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"details": "Details",
|
||||
"copyJobId": "Copy Job ID",
|
||||
"copied": "Copied",
|
||||
"relativeTime": {
|
||||
@@ -350,6 +351,11 @@
|
||||
"nodeSlotsError": "Node Slots Error",
|
||||
"nodeWidgetsError": "Node Widgets Error",
|
||||
"frameNodes": "Frame Nodes",
|
||||
"arrange": "Arrange",
|
||||
"arrangeVertically": "Arrange vertically",
|
||||
"arrangeHorizontally": "Arrange horizontally",
|
||||
"arrangeAsGrid": "Arrange as grid",
|
||||
"arrangeSpacing": "Arrangement spacing",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"play": "Play",
|
||||
@@ -3549,6 +3555,7 @@
|
||||
"parameters": "Parameters",
|
||||
"nodes": "Nodes",
|
||||
"info": "Info",
|
||||
"infoFor": "Info for {item}",
|
||||
"color": "Node color",
|
||||
"pinned": "Pinned",
|
||||
"bypass": "Bypass",
|
||||
@@ -3568,6 +3575,7 @@
|
||||
"hideInput": "Hide input",
|
||||
"showInput": "Show input",
|
||||
"locateNode": "Locate node on canvas",
|
||||
"locateNodeFor": "Locate {item}",
|
||||
"favorites": "FAVORITED INPUTS",
|
||||
"favoritesNone": "NO FAVORITED INPUTS",
|
||||
"favoritesNoneTooltip": "Star widgets to quickly access them without selecting nodes",
|
||||
@@ -3601,6 +3609,7 @@
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
"getHelpTooltip": "Report this error and we'll help you resolve it",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
@@ -3805,6 +3814,15 @@
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} rejected the value for {inputName}."
|
||||
},
|
||||
"unknown_validation_error": {
|
||||
"title": "Validation failed",
|
||||
"message": "A node returned a validation error ComfyUI does not recognize.",
|
||||
"details": "{nodeName} returned an unrecognized validation error: {errorType}",
|
||||
"detailsWithRawDetails": "{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} returned an unrecognized validation error."
|
||||
},
|
||||
"exception_during_inner_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow couldn't validate a connected node.",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 1:1 to an API error type. Simple validation mappings stay with the validation
|
||||
// resolver.
|
||||
export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection'
|
||||
export const UNKNOWN_VALIDATION_ERROR_CATALOG_ID = 'unknown_validation_error'
|
||||
export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
|
||||
export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
|
||||
export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
|
||||
|
||||
@@ -144,6 +144,26 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves unknown validation errors to fallback catalog copy', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError('value_not_valid', undefined, 'some detail'),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'unknown_validation_error',
|
||||
displayTitle: 'Validation failed',
|
||||
displayMessage:
|
||||
'A node returned a validation error ComfyUI does not recognize.',
|
||||
displayDetails:
|
||||
'KSampler returned an unrecognized validation error (value_not_valid): some detail',
|
||||
displayItemLabel: 'KSampler',
|
||||
toastTitle: 'Validation failed',
|
||||
toastMessage: 'KSampler returned an unrecognized validation error.'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to raw API copy when catalog keys are missing in the active locale', () => {
|
||||
const originalLocale = i18n.global.locale.value
|
||||
const originalKoMessages = i18n.global.getLocaleMessage('ko')
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
|
||||
import type {
|
||||
ResolvedCatalogErrorMessage,
|
||||
ResolvedErrorMessage,
|
||||
RunErrorMessageSource
|
||||
} from './types'
|
||||
|
||||
import { resolveExecutionErrorMessage } from './executionErrorResolver'
|
||||
import { resolveMissingErrorMessage } from './missingErrorResolver'
|
||||
@@ -9,6 +13,15 @@ import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
|
||||
// own the actual matching/copy rules so this file stays as the routing boundary.
|
||||
export { resolveMissingErrorMessage }
|
||||
|
||||
export function resolveRunErrorMessage(
|
||||
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>
|
||||
): ResolvedCatalogErrorMessage
|
||||
export function resolveRunErrorMessage(
|
||||
source: Extract<RunErrorMessageSource, { kind: 'execution' }>
|
||||
): ResolvedCatalogErrorMessage
|
||||
export function resolveRunErrorMessage(
|
||||
source: RunErrorMessageSource
|
||||
): ResolvedErrorMessage
|
||||
export function resolveRunErrorMessage(
|
||||
source: RunErrorMessageSource
|
||||
): ResolvedErrorMessage {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
|
||||
import type {
|
||||
ResolvedCatalogErrorMessage,
|
||||
RunErrorMessageSource
|
||||
} from './types'
|
||||
|
||||
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
|
||||
import type { ErrorResolveContext } from './catalogI18n'
|
||||
@@ -11,7 +14,7 @@ type ExecutionErrorResolveContext = Pick<ErrorResolveContext, 'nodeDisplayName'>
|
||||
export function resolveExecutionErrorMessage(
|
||||
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
|
||||
context: ExecutionErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
): ResolvedCatalogErrorMessage {
|
||||
const exceptionMessage = error.exception_message.trim()
|
||||
const match = resolveRuntimeCatalogMatch({
|
||||
exceptionType: error.exception_type,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ResolvedErrorMessage } from './types'
|
||||
import type { ResolvedCatalogErrorMessage } from './types'
|
||||
|
||||
import {
|
||||
normalizeNodeName,
|
||||
@@ -19,7 +19,7 @@ export function resolveRuntimeCatalogCopy(
|
||||
params?: CatalogParams
|
||||
detailsFallback?: string
|
||||
} = {}
|
||||
): ResolvedErrorMessage {
|
||||
): ResolvedCatalogErrorMessage {
|
||||
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const params = { nodeName, ...options.params }
|
||||
@@ -27,7 +27,7 @@ export function resolveRuntimeCatalogCopy(
|
||||
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
|
||||
|
||||
const displayMessage = resolveMessage('message')
|
||||
const result: ResolvedErrorMessage = {
|
||||
const result: ResolvedCatalogErrorMessage = {
|
||||
catalogId,
|
||||
displayTitle: resolveMessage('title'),
|
||||
displayMessage
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface ResolvedErrorMessage {
|
||||
toastMessage?: string
|
||||
}
|
||||
|
||||
export type ResolvedCatalogErrorMessage = ResolvedErrorMessage & {
|
||||
catalogId: string
|
||||
}
|
||||
|
||||
export type ResolvedMissingErrorMessage = ResolvedErrorMessage & {
|
||||
displayTitle: string
|
||||
displayMessage: string
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { NodeValidationError, ResolvedErrorMessage } from './types'
|
||||
import type { NodeValidationError, ResolvedCatalogErrorMessage } from './types'
|
||||
|
||||
import {
|
||||
IMAGE_NOT_LOADED_CATALOG_ID,
|
||||
MISSING_CONNECTION_CATALOG_ID
|
||||
MISSING_CONNECTION_CATALOG_ID,
|
||||
UNKNOWN_VALIDATION_ERROR_CATALOG_ID
|
||||
} from './catalogIds'
|
||||
import {
|
||||
normalizeNodeName,
|
||||
@@ -117,6 +118,11 @@ const IMAGE_NOT_LOADED_VALIDATION_RULE = {
|
||||
copyKeys: DEFAULT_COPY_KEYS
|
||||
} satisfies ValidationCatalogRule
|
||||
|
||||
const UNKNOWN_VALIDATION_ERROR_RULE = {
|
||||
catalogId: UNKNOWN_VALIDATION_ERROR_CATALOG_ID,
|
||||
itemLabel: 'node'
|
||||
} satisfies ValidationCatalogRule
|
||||
|
||||
function getInputName(error: NodeValidationError): string {
|
||||
const inputName = error.extra_info?.input_name
|
||||
return (
|
||||
@@ -228,7 +234,7 @@ function getValueSpecificCopyKeys(
|
||||
}
|
||||
|
||||
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
return error.details.trim()
|
||||
return error.details?.trim()
|
||||
? {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
toastMessageKey: 'toastMessageWithRawDetails'
|
||||
@@ -237,7 +243,7 @@ function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
}
|
||||
|
||||
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
if (!error.details.trim()) return DEFAULT_COPY_KEYS
|
||||
if (!error.details?.trim()) return DEFAULT_COPY_KEYS
|
||||
|
||||
return {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
@@ -272,16 +278,17 @@ function resolveValidationCatalogCopy(
|
||||
context: ErrorResolveContext,
|
||||
localeKey: string,
|
||||
rule: ValidationCatalogRule
|
||||
): ResolvedErrorMessage {
|
||||
): ResolvedCatalogErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const trimmedDetails = error.details.trim()
|
||||
const trimmedDetails = error.details?.trim() ?? ''
|
||||
const rawDetails =
|
||||
error.type === 'dependency_cycle'
|
||||
? formatDependencyCycleDetails(trimmedDetails)
|
||||
: trimmedDetails
|
||||
const params = {
|
||||
...getValidationParams(error, nodeName, inputName),
|
||||
errorType: error.type || 'unknown',
|
||||
rawDetails
|
||||
}
|
||||
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
|
||||
@@ -306,7 +313,7 @@ function resolveValidationCatalogCopy(
|
||||
),
|
||||
displayDetails: translateOptionalCatalogMessage(
|
||||
`${keyPrefix}.${copyKeys.detailsKey}`,
|
||||
error.details,
|
||||
error.details ?? '',
|
||||
params
|
||||
),
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
@@ -330,7 +337,7 @@ function resolveValidationCatalogCopy(
|
||||
export function resolveNodeValidationErrorMessage(
|
||||
error: NodeValidationError,
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
): ResolvedCatalogErrorMessage {
|
||||
if (isImageNotLoadedValidationError(error)) {
|
||||
return resolveValidationCatalogCopy(
|
||||
error,
|
||||
@@ -341,7 +348,17 @@ export function resolveNodeValidationErrorMessage(
|
||||
}
|
||||
|
||||
const rule = VALIDATION_ERROR_RULES[error.type]
|
||||
if (!rule) return {}
|
||||
if (!rule) {
|
||||
return resolveValidationCatalogCopy(
|
||||
error,
|
||||
context,
|
||||
'unknown_validation_error',
|
||||
{
|
||||
...UNKNOWN_VALIDATION_ERROR_RULE,
|
||||
copyKeys: getRawDetailsOnlyCopyKeys(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return resolveValidationCatalogCopy(error, context, error.type, rule)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal :to="portalTarget" :disabled="isPortalDisabled">
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
data-capture-wheel="true"
|
||||
data-testid="widget-select-default-overlay"
|
||||
@@ -161,7 +161,6 @@ import {
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -242,19 +241,10 @@ const searchInputContainerRef = ref<HTMLElement>()
|
||||
const { handleFocusOutside, handleViewportPointerDown } =
|
||||
useRestoreFocusOnViewportPointer(focusSearchInput)
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const widgetOptions = computed(
|
||||
() => widget.options as SelectWidgetOptions | undefined
|
||||
)
|
||||
|
||||
const portalTarget = computed(() => {
|
||||
const appendTo = transformCompatProps.value.appendTo
|
||||
return appendTo === 'self' ? undefined : appendTo
|
||||
})
|
||||
|
||||
const isPortalDisabled = computed(() => !portalTarget.value)
|
||||
|
||||
const disabled = computed(() => Boolean(widgetOptions.value?.disabled))
|
||||
const placeholder = computed(() => widgetOptions.value?.placeholder ?? '')
|
||||
const filterPlaceholder = computed(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
|
||||
@@ -53,12 +52,9 @@ const outputMediaAssets = isCloud
|
||||
? useFlatOutputAssets()
|
||||
: useAssetsApi('output')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
const combinedProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const getAssetData = () => {
|
||||
const nodeType: string | undefined =
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'size-full resize-none text-xs',
|
||||
'size-full resize-none text-(length:--comfy-textarea-font-size) leading-normal',
|
||||
!hideLayoutField && 'pt-5',
|
||||
// Avoid overflow-auto when idle to prevent per-textarea compositing layers.
|
||||
'overflow-hidden hover:overflow-auto focus:overflow-auto'
|
||||
|
||||