mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-26 07:57:36 +00:00
Compare commits
12 Commits
cloud/v1.4
...
austin/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
510c6c4cec | ||
|
|
0132e462c8 | ||
|
|
08ee925811 | ||
|
|
fb5b4a62ba | ||
|
|
cb62604d21 | ||
|
|
d02c5d374f | ||
|
|
c0ef283a05 | ||
|
|
d405002127 | ||
|
|
abd233d10d | ||
|
|
e1049a99a3 | ||
|
|
3da6e1766e | ||
|
|
52830a9e73 |
4
apps/website/public/favicon-dark.svg
Normal file
4
apps/website/public/favicon-dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
11
apps/website/public/favicon-light.svg
Normal file
11
apps/website/public/favicon-light.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -71,8 +71,18 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
|
||||
@@ -51,6 +51,20 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -128,7 +128,8 @@ export const TestIds = {
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image',
|
||||
slotConnectionDot: 'slot-connection-dot'
|
||||
slotConnectionDot: 'slot-connection-dot',
|
||||
imageGrid: 'image-grid'
|
||||
},
|
||||
selectionToolbox: {
|
||||
root: 'selection-toolbox',
|
||||
|
||||
@@ -15,7 +15,9 @@ export class VueNodeFixture {
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
public readonly imageGrid: Locator
|
||||
public readonly content: Locator
|
||||
public readonly resize: { br: Locator }
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -28,7 +30,9 @@ export class VueNodeFixture {
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
this.imageGrid = locator.getByTestId(TestIds.node.imageGrid)
|
||||
this.content = locator.locator('.lg-node-content')
|
||||
this.resize = { br: locator.getByRole('button', { name: 'bottom-right' }) }
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
wstest(
|
||||
'Will not use stale litegraph previews',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
)
|
||||
}
|
||||
|
||||
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
await expect.poll(getNodeOutput).toBe('test1.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const resolvableFile = { filename: 'example.png', type: 'input' }
|
||||
executionHelper.executed('', '1', { images: [resolvableFile] })
|
||||
await expect.poll(getNodeOutput).toBe('example.png')
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
await node.imagePreview.hover()
|
||||
await node.imagePreview
|
||||
.getByRole('button', { name: 'Edit or mask image' })
|
||||
.click()
|
||||
|
||||
// On previous versions, attempting to open the mask editor here would
|
||||
// incorrectly reference the non-existant test1.png
|
||||
// This causes the mask editor to throw in setup and not display
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -309,6 +309,50 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
@@ -138,3 +142,43 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function getColumns(locator: Locator) {
|
||||
return await locator.locator('img').evaluateAll((images) => {
|
||||
const ys = images.map((image) => image.getBoundingClientRect().y)
|
||||
return ys.filter((y) => y === ys[0]).length
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
wstest(
|
||||
'Image previews tile to fit node',
|
||||
async ({ comfyMouse, 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')
|
||||
|
||||
await test.step('Inject multiple previews', async () => {
|
||||
const file = { filename: 'example.png', type: 'input' }
|
||||
const images = new Array(100).fill(file)
|
||||
execution.executed('', '1', { images })
|
||||
await expect(node.imageGrid.locator('img')).toHaveCount(100)
|
||||
})
|
||||
|
||||
await expect.poll(() => getColumns(node.imageGrid)).toBe(10)
|
||||
await comfyMouse.resizeByDragging(node.resize.br, { x: 200 })
|
||||
await expect.poll(() => getColumns(node.imageGrid)).toBeGreaterThan(10)
|
||||
await comfyMouse.resizeByDragging(node.resize.br, { x: -200, y: 200 })
|
||||
await expect.poll(() => getColumns(node.imageGrid)).toBeLessThan(10)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push(node.widgets![0])
|
||||
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = node.widgets![0]
|
||||
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
node.widgets!.splice(0, 0, {
|
||||
...node.widgets![0],
|
||||
name: 'added_widget_3'
|
||||
})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.14",
|
||||
"version": "1.46.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -11,17 +11,39 @@
|
||||
:aria-label="$t('load3d.switchCamera')"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
|
||||
<i class="pi pi-camera text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
|
||||
@@ -29,17 +29,47 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
te: vi.fn(() => false),
|
||||
st: vi.fn((_key: string, fallback: string) => fallback),
|
||||
t: vi.fn((key: string, params?: { count?: number }) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = params?.count ?? 0
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
return key
|
||||
})
|
||||
}))
|
||||
vi.mock('@/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'errorCatalog.validationErrors.required_input_missing.title':
|
||||
'Missing connection',
|
||||
'errorCatalog.validationErrors.required_input_missing.message':
|
||||
'Required input slots have no connection feeding them.',
|
||||
'errorCatalog.validationErrors.required_input_missing.details':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.itemLabel':
|
||||
'{nodeName} - {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastTitle':
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
|
||||
const interpolate = (
|
||||
message: string,
|
||||
params?: Record<string, string | number>
|
||||
) =>
|
||||
message.replace(/\{(\w+)\}/g, (match, paramName) =>
|
||||
params?.[paramName] === undefined ? match : String(params[paramName])
|
||||
)
|
||||
|
||||
return {
|
||||
te: vi.fn((key: string) => key in messages),
|
||||
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
|
||||
t: vi.fn((key: string, params?: Record<string, string | number>) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = Number(params?.count ?? 0)
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
|
||||
return interpolate(messages[key] ?? key, params)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => ({
|
||||
@@ -412,10 +442,16 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
if (execGroups[0].type !== 'execution') return
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
|
||||
'KSampler failed'
|
||||
)
|
||||
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
|
||||
message: 'RuntimeError: CUDA out of memory',
|
||||
details: 'line 1\nline 2',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
})
|
||||
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
|
||||
// bypass catalog display fields until targeted runtime handling lands.
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes prompt error when present', async () => {
|
||||
@@ -428,7 +464,8 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -417,12 +417,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const resolvedDisplay = resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName: e.node_type,
|
||||
isCloud
|
||||
})
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
@@ -433,8 +427,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolvedDisplay
|
||||
exceptionType: e.exception_type
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
|
||||
@@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
@@ -71,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
const NodeSearchContentStub = defineComponent({
|
||||
name: 'NodeSearchContent',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
filters: { type: Array, default: () => [] },
|
||||
defaultRootFilter: { type: String, default: null }
|
||||
},
|
||||
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
|
||||
setup(_, { emit }) {
|
||||
@@ -79,7 +83,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
template:
|
||||
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
@@ -276,4 +281,75 @@ describe('NodeSearchBoxPopover', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRootFilter on dialog open', () => {
|
||||
function setGraphNodes(nodes: unknown[]) {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
}
|
||||
|
||||
async function openSearch() {
|
||||
useSearchBoxStore().visible = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('defaults to Essentials when the graph is empty', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to Essentials when the canvas is not yet available', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to null when the graph has nodes', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-evaluates each time the dialog opens', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
|
||||
useSearchBoxStore().visible = false
|
||||
await nextTick()
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<div v-if="useSearchBoxV2" role="search" class="relative">
|
||||
<NodeSearchContent
|
||||
:filters="nodeFilters"
|
||||
:default-root-filter="defaultRootFilter"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@@ -76,6 +77,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
@@ -87,6 +90,7 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
@@ -102,6 +106,13 @@ const enableNodePreview = computed(
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
? RootCategory.Essentials
|
||||
: null
|
||||
})
|
||||
function getNewNodeLocation(): Point {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
@@ -126,7 +137,6 @@ function clearFilters() {
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
@@ -230,6 +231,48 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply defaultRootFilter when provided and category is available', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
display_name: 'Essential Node',
|
||||
essentials_category: 'basic'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'FrequentNode',
|
||||
display_name: 'Frequent Node'
|
||||
})
|
||||
])
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['FrequentNode']
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
|
||||
@@ -141,8 +141,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
const { filters, defaultRootFilter = null } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
defaultRootFilter?: RootCategoryId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -193,8 +194,12 @@ function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(
|
||||
defaultRootFilter === RootCategory.Essentials &&
|
||||
!nodeAvailability.value.essential
|
||||
? null
|
||||
: defaultRootFilter
|
||||
)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setRetainViewOnReload: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -568,17 +569,21 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
composable.cameraConfig.value.retainViewOnReload = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null
|
||||
state: null,
|
||||
retainViewOnReload: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -483,6 +483,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
|
||||
@@ -2,7 +2,10 @@ import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
import type {
|
||||
CameraState,
|
||||
GizmoMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const {
|
||||
cloneSkinnedMock,
|
||||
@@ -769,6 +772,133 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('retainViewOnReload', () => {
|
||||
function setupLoadInternal(initialFlag: boolean) {
|
||||
const getCameraState = vi.fn<() => CameraState>(() => ({
|
||||
position: new THREE.Vector3(1, 2, 3),
|
||||
target: new THREE.Vector3(),
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
}))
|
||||
const setCameraState = vi.fn()
|
||||
const getCurrentCameraType = vi.fn(() => 'perspective' as const)
|
||||
const loaderLoadModel = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(ctx.load3d, {
|
||||
cameraManager: {
|
||||
...ctx.cameraManager,
|
||||
getCameraState,
|
||||
setCameraState,
|
||||
getCurrentCameraType
|
||||
},
|
||||
controlsManager: { ...ctx.controlsManager, reset: vi.fn() },
|
||||
loaderManager: { loadModel: loaderLoadModel },
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: new THREE.Group(),
|
||||
originalModel: null
|
||||
},
|
||||
animationManager: {
|
||||
...ctx.animationManager,
|
||||
setupModelAnimations: vi.fn()
|
||||
},
|
||||
handleResize: vi.fn(),
|
||||
retainViewOnReload: initialFlag,
|
||||
hasLoadedModel: false
|
||||
})
|
||||
return { getCameraState, setCameraState, getCurrentCameraType }
|
||||
}
|
||||
|
||||
it('first load uses default framing even with retain enabled', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
|
||||
// hasLoadedModel started false, so retain shouldn't kick in yet.
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subsequent load captures camera state, skips reset, and restores it', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retain when the flag is off, even after a prior load', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles to the saved camera type before restoring state when types differ', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
mocks.getCameraState.mockImplementation(() => ({
|
||||
position: new THREE.Vector3(0, 0, 5),
|
||||
target: new THREE.Vector3(),
|
||||
zoom: 1,
|
||||
cameraType: 'orthographic'
|
||||
}))
|
||||
// First load (active type stays perspective per the default mock).
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.toggleCamera as ReturnType<typeof vi.fn>).mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
|
||||
'orthographic'
|
||||
)
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.clearModel()
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.setRetainViewOnReload(true)
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('hides the gizmo helper during capture and restores it after success', async () => {
|
||||
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
|
||||
|
||||
@@ -104,6 +104,8 @@ class Load3d {
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private retainViewOnReload: boolean = false
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
@@ -564,13 +566,25 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
public setRetainViewOnReload(value: boolean): void {
|
||||
this.retainViewOnReload = value
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
// First load always uses default framing; retain only applies on reload.
|
||||
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
: null
|
||||
|
||||
if (!shouldRetainView) {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
}
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.animationManager.dispose()
|
||||
@@ -583,6 +597,18 @@ class Load3d {
|
||||
this.modelManager.currentModel,
|
||||
this.modelManager.originalModel
|
||||
)
|
||||
this.hasLoadedModel = true
|
||||
}
|
||||
|
||||
if (savedCameraState) {
|
||||
// setupForModel runs during loadModel and clobbers the camera; restore on top.
|
||||
if (
|
||||
savedCameraState.cameraType !==
|
||||
this.cameraManager.getCurrentCameraType()
|
||||
) {
|
||||
this.toggleCamera(savedCameraState.cameraType)
|
||||
}
|
||||
this.cameraManager.setCameraState(savedCameraState)
|
||||
}
|
||||
|
||||
this.handleResize()
|
||||
@@ -607,6 +633,7 @@ class Load3d {
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this.hasLoadedModel = false
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
retainViewOnReload?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "تم الإيقاف مؤقتًا",
|
||||
"resume": "استئناف التنزيل"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "مدخل غير معروف",
|
||||
"nodeName": "هذه العقدة"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة أو معاينة الصورة) لإنتاج نتيجة."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً. لم يتم خصم أي رصيد.",
|
||||
"toastMessageLocal": "حدث خطأ أثناء تنفيذ هذه العقدة. تحقق من المدخلات أو جرّب إعداداً مختلفاً.",
|
||||
"toastTitle": "فشل {nodeName}"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "بعض منافذ الإدخال المطلوبة غير متصلة.",
|
||||
"title": "الاتصال مفقود",
|
||||
"toastMessage": "{nodeName} تفتقد إلى مدخل مطلوب: {inputName}",
|
||||
"toastTitle": "مدخل مطلوب مفقود"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "جاري إعادة تحميل النموذج...",
|
||||
"removeBackgroundImage": "إزالة صورة الخلفية",
|
||||
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
|
||||
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
|
||||
"scene": "المشهد",
|
||||
"showGrid": "عرض الشبكة",
|
||||
"showSkeleton": "إظهار الهيكل العظمي",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "عادي",
|
||||
"parameters": "المعلمات",
|
||||
"pinned": "مثبت",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "بيانات سير العمل المرسلة إلى الخادم فارغة. قد يكون هذا خطأ غير متوقع في النظام."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "سير العمل لا يحتوي على أي عقدة إخراج (مثل حفظ الصورة، معاينة الصورة) لإنتاج نتيجة."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى المحاولة لاحقاً."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "واجه الخادم خطأ غير متوقع. يرجى مراجعة سجلات الخادم."
|
||||
}
|
||||
},
|
||||
"properties": "الخصائص",
|
||||
"removeFavorite": "إزالة من المفضلة",
|
||||
"resetAllParameters": "إعادة تعيين جميع المعلمات",
|
||||
|
||||
@@ -1952,6 +1952,7 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"retainViewOnReload": "Lock camera view across model reloads",
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
@@ -3667,28 +3668,145 @@
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Required input missing",
|
||||
"toastMessage": "{nodeName} is missing a required input: {inputName}"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
},
|
||||
"bad_linked_input": {
|
||||
"title": "Invalid connection",
|
||||
"message": "A node connection could not be read correctly.",
|
||||
"details": "{nodeName} has an invalid connection for {inputName}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid connection",
|
||||
"toastMessage": "{nodeName} has an invalid connection for {inputName}."
|
||||
},
|
||||
"return_type_mismatch": {
|
||||
"title": "Invalid connection",
|
||||
"message": "Connected nodes are using incompatible input and output types.",
|
||||
"details": "{nodeName} has an incompatible connection for {inputName}.",
|
||||
"detailsWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid connection",
|
||||
"toastMessage": "{nodeName} has an incompatible connection for {inputName}.",
|
||||
"toastMessageWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}."
|
||||
},
|
||||
"invalid_input_type": {
|
||||
"title": "Invalid input",
|
||||
"message": "An input value has the wrong type.",
|
||||
"details": "{nodeName} couldn't convert {inputName} to the expected type.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} couldn't convert {inputName} to the expected type.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}."
|
||||
},
|
||||
"value_smaller_than_min": {
|
||||
"title": "Input out of range",
|
||||
"message": "Some input values are outside the allowed range.",
|
||||
"details": "{nodeName} has a value below the minimum for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Input out of range",
|
||||
"toastMessage": "{nodeName} has a value below the minimum for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}."
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"title": "Input out of range",
|
||||
"message": "Some input values are outside the allowed range.",
|
||||
"details": "{nodeName} has a value above the maximum for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Input out of range",
|
||||
"toastMessage": "{nodeName} has a value above the maximum for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}."
|
||||
},
|
||||
"value_not_in_list": {
|
||||
"title": "Invalid input",
|
||||
"message": "Some input values are not available for this node.",
|
||||
"details": "{nodeName} has an unsupported value for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} has an unsupported value for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available."
|
||||
},
|
||||
"custom_validation_failed": {
|
||||
"title": "Invalid input",
|
||||
"message": "A node rejected one or more input values.",
|
||||
"details": "{nodeName} rejected the value for {inputName}.",
|
||||
"detailsWithRawDetails": "{nodeName} failed custom validation: {rawDetails}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} rejected the value for {inputName}."
|
||||
},
|
||||
"exception_during_inner_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow couldn't validate a connected node.",
|
||||
"details": "{nodeName} couldn't validate {inputName}.",
|
||||
"detailsWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} couldn't validate {inputName}.",
|
||||
"toastMessageWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}"
|
||||
},
|
||||
"exception_during_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow could not be validated because a node validation check failed unexpectedly.",
|
||||
"details": "{nodeName} failed during validation.",
|
||||
"detailsWithRawDetails": "{nodeName} failed during validation: {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "{nodeName} failed",
|
||||
"toastMessageLocal": "This node threw an error during execution. Check its inputs or try a different configuration.",
|
||||
"toastMessageCloud": "This node threw an error during execution. Check its inputs or try a different configuration. No credits charged."
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} failed during validation.",
|
||||
"toastMessageWithRawDetails": "{nodeName} failed during validation: {rawDetails}"
|
||||
},
|
||||
"dependency_cycle": {
|
||||
"title": "Invalid workflow",
|
||||
"message": "The workflow has a circular node connection.",
|
||||
"details": "{nodeName} is part of a circular connection.",
|
||||
"detailsWithRawDetails": "{nodeName} is part of a circular connection: {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Invalid workflow",
|
||||
"toastMessage": "{nodeName} is part of a circular connection."
|
||||
},
|
||||
"image_not_loaded": {
|
||||
"title": "Image not loaded",
|
||||
"message": "The system couldn't load this image.",
|
||||
"details": "The image for {nodeName} couldn't be loaded. Try adding it again.",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Input image couldn't be loaded",
|
||||
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"prompt_no_outputs": {
|
||||
"title": "Prompt has no outputs",
|
||||
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
|
||||
},
|
||||
"no_prompt": {
|
||||
"title": "Workflow data is empty",
|
||||
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
|
||||
},
|
||||
"server_error_local": {
|
||||
"title": "Server error",
|
||||
"desc": "The server encountered an unexpected error. Please check the server logs."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"title": "Server error",
|
||||
"desc": "The server encountered an unexpected error. Please try again later."
|
||||
},
|
||||
"missing_node_type": {
|
||||
"title": "Missing node type",
|
||||
"desc": "A node type is missing or unavailable. The workflow may be corrupted or require a custom node."
|
||||
},
|
||||
"prompt_outputs_failed_validation": {
|
||||
"title": "Prompt validation failed",
|
||||
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
|
||||
},
|
||||
"image_not_loaded": {
|
||||
"title": "Image not loaded",
|
||||
"desc": "The system couldn't load this image."
|
||||
},
|
||||
"out_of_memory": {
|
||||
"title": "Generation failed",
|
||||
"descLocal": "Not enough GPU memory. Try reducing complexity and run again.",
|
||||
"descCloud": "Not enough GPU memory. Try reducing complexity and run again. No credits charged."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Pausado",
|
||||
"resume": "Reanudar descarga"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "entrada desconocida",
|
||||
"nodeName": "Este nodo"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Los datos del flujo de trabajo enviados al servidor están vacíos. Esto puede ser un error inesperado del sistema."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "El flujo de trabajo no contiene ningún nodo de salida (por ejemplo, Guardar imagen, Vista previa de imagen) para producir un resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, inténtalo de nuevo más tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, revisa los registros del servidor."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Este nodo generó un error durante la ejecución. Revisa sus entradas o prueba una configuración diferente. No se cobraron créditos.",
|
||||
"toastMessageLocal": "Este nodo generó un error durante la ejecución. Revisa sus entradas o prueba una configuración diferente.",
|
||||
"toastTitle": "{nodeName} falló"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} carece de una entrada requerida: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Las ranuras de entrada requeridas no tienen ninguna conexión.",
|
||||
"title": "Conexión faltante",
|
||||
"toastMessage": "{nodeName} carece de una entrada requerida: {inputName}",
|
||||
"toastTitle": "Falta entrada requerida"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Recargando modelo...",
|
||||
"removeBackgroundImage": "Eliminar imagen de fondo",
|
||||
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
|
||||
"retainViewOnReload": "Bloquear la vista de la cámara al recargar el modelo",
|
||||
"scene": "Escena",
|
||||
"showGrid": "Mostrar cuadrícula",
|
||||
"showSkeleton": "Mostrar esqueleto",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Parámetros",
|
||||
"pinned": "Fijado",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Los datos del flujo de trabajo enviados al servidor están vacíos. Esto puede ser un error inesperado del sistema."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "El flujo de trabajo no contiene ningún nodo de salida (por ejemplo, Guardar imagen, Vista previa de imagen) para producir un resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, inténtalo de nuevo más tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "El servidor encontró un error inesperado. Por favor, revisa los registros del servidor."
|
||||
}
|
||||
},
|
||||
"properties": "Propiedades",
|
||||
"removeFavorite": "Quitar de favoritos",
|
||||
"resetAllParameters": "Restablecer todos los parámetros",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "متوقف شده",
|
||||
"resume": "ادامه دانلود"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "ورودی نامشخص",
|
||||
"nodeName": "این نود"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "دادههای ورکفلو ارسالشده به سرور خالی است. این ممکن است یک خطای غیرمنتظره سیستمی باشد."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "در این ورکفلو هیچ نود خروجی (مانند Save Image یا Preview Image) برای تولید نتیجه وجود ندارد."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "سرور با یک خطای غیرمنتظره مواجه شد. لطفاً بعداً دوباره تلاش کنید."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "سرور با یک خطای غیرمنتظره مواجه شد. لطفاً لاگهای سرور را بررسی کنید."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "این نود هنگام اجرا با خطا مواجه شد. ورودیها را بررسی کنید یا پیکربندی دیگری را امتحان نمایید. هیچ اعتباری کسر نشد.",
|
||||
"toastMessageLocal": "این نود هنگام اجرا با خطا مواجه شد. ورودیها را بررسی کنید یا پیکربندی دیگری را امتحان نمایید.",
|
||||
"toastTitle": "{nodeName} با خطا مواجه شد"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} یک ورودی ضروری را ندارد: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "ورودیهای ضروری بدون اتصال هستند.",
|
||||
"title": "اتصال وجود ندارد",
|
||||
"toastMessage": "{nodeName} یک ورودی ضروری را ندارد: {inputName}",
|
||||
"toastTitle": "ورودی ضروری وجود ندارد"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "در حال بارگذاری مجدد مدل...",
|
||||
"removeBackgroundImage": "حذف تصویر پسزمینه",
|
||||
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
|
||||
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
|
||||
"scene": "صحنه",
|
||||
"showGrid": "نمایش شبکه",
|
||||
"showSkeleton": "نمایش اسکلت",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "عادی",
|
||||
"parameters": "پارامترها",
|
||||
"pinned": "سنجاق شده",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "دادههای گردشکار ارسالشده به سرور خالی است. این ممکن است یک خطای غیرمنتظره سیستمی باشد."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "گردشکار هیچ نود خروجی (مانند Save Image یا Preview Image) برای تولید نتیجه ندارد."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "سرور با خطای غیرمنتظرهای مواجه شد. لطفاً بعداً دوباره تلاش کنید."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "سرور با خطای غیرمنتظرهای مواجه شد. لطفاً لاگهای سرور را بررسی کنید."
|
||||
}
|
||||
},
|
||||
"properties": "ویژگیها",
|
||||
"removeFavorite": "حذف از علاقهمندیها",
|
||||
"resetAllParameters": "بازنشانی همه پارامترها",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "En pause",
|
||||
"resume": "Reprendre le téléchargement"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "entrée inconnue",
|
||||
"nodeName": "Ce nœud"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Les données du workflow envoyées au serveur sont vides. Il peut s’agir d’une erreur système inattendue."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Le workflow ne contient aucun nœud de sortie (par exemple, Enregistrer l’image, Prévisualiser l’image) pour produire un résultat."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez réessayer plus tard."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez consulter les journaux du serveur."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Ce nœud a généré une erreur lors de l’exécution. Vérifiez ses entrées ou essayez une autre configuration. Aucun crédit n’a été déduit.",
|
||||
"toastMessageLocal": "Ce nœud a généré une erreur lors de l’exécution. Vérifiez ses entrées ou essayez une autre configuration.",
|
||||
"toastTitle": "Échec de {nodeName}"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} nécessite une entrée obligatoire : {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Des entrées requises ne sont pas connectées.",
|
||||
"title": "Connexion manquante",
|
||||
"toastMessage": "{nodeName} nécessite une entrée obligatoire : {inputName}",
|
||||
"toastTitle": "Entrée requise manquante"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Rechargement du modèle...",
|
||||
"removeBackgroundImage": "Supprimer l'image de fond",
|
||||
"resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie",
|
||||
"retainViewOnReload": "Verrouiller la vue de la caméra lors du rechargement du modèle",
|
||||
"scene": "Scène",
|
||||
"showGrid": "Afficher la grille",
|
||||
"showSkeleton": "Afficher le squelette",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Paramètres",
|
||||
"pinned": "Épinglé",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Les données du flux de travail envoyées au serveur sont vides. Il peut s'agir d'une erreur système inattendue."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Le flux de travail ne contient aucun nœud de sortie (par exemple, Enregistrer l'image, Prévisualiser l'image) pour produire un résultat."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez réessayer plus tard."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Le serveur a rencontré une erreur inattendue. Veuillez consulter les journaux du serveur."
|
||||
}
|
||||
},
|
||||
"properties": "Propriétés",
|
||||
"removeFavorite": "Retirer des favoris",
|
||||
"resetAllParameters": "Réinitialiser tous les paramètres",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "一時停止",
|
||||
"resume": "ダウンロードを再開"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "不明な入力",
|
||||
"nodeName": "このノード"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "サーバーに送信されたワークフローデータが空です。これは予期しないシステムエラーの可能性があります。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "ワークフローに出力ノード(例:画像を保存、画像をプレビュー)が含まれていないため、結果を生成できません。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。しばらくしてから再度お試しください。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。サーバーログを確認してください。"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "このノードの実行中にエラーが発生しました。入力内容を確認するか、別の設定をお試しください。クレジットは消費されていません。",
|
||||
"toastMessageLocal": "このノードの実行中にエラーが発生しました。入力内容を確認するか、別の設定をお試しください。",
|
||||
"toastTitle": "{nodeName} の実行に失敗しました"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} に必須入力 {inputName} がありません。",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "必須入力スロットに接続がありません。",
|
||||
"title": "接続がありません",
|
||||
"toastMessage": "{nodeName} に必須入力 {inputName} がありません。",
|
||||
"toastTitle": "必須入力がありません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "モデルを再読み込み中...",
|
||||
"removeBackgroundImage": "背景画像を削除",
|
||||
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
|
||||
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
|
||||
"scene": "シーン",
|
||||
"showGrid": "グリッドを表示",
|
||||
"showSkeleton": "スケルトンを表示",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "ノーマル",
|
||||
"parameters": "パラメータ",
|
||||
"pinned": "ピン留め",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "サーバーに送信されたワークフローデータが空です。これは予期しないシステムエラーの可能性があります。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "ワークフローに結果を生成する出力ノード(例:画像を保存、画像をプレビュー)が含まれていません。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。しばらくしてから再度お試しください。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "サーバーで予期しないエラーが発生しました。サーバーログをご確認ください。"
|
||||
}
|
||||
},
|
||||
"properties": "プロパティ",
|
||||
"removeFavorite": "お気に入りを解除",
|
||||
"resetAllParameters": "すべてのパラメータをリセット",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "일시 중지됨",
|
||||
"resume": "다운로드 재개"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "알 수 없는 입력",
|
||||
"nodeName": "이 노드"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "서버로 전송된 워크플로우 데이터가 비어 있습니다. 시스템 오류일 수 있습니다."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "워크플로우에 결과를 생성할 출력 노드(예: 이미지 저장, 이미지 미리보기)가 없습니다."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 서버 로그를 확인해 주세요."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "이 노드는 실행 중 오류가 발생했습니다. 입력값을 확인하거나 다른 설정을 시도해 보세요. 크레딧은 차감되지 않았습니다.",
|
||||
"toastMessageLocal": "이 노드는 실행 중 오류가 발생했습니다. 입력값을 확인하거나 다른 설정을 시도해 보세요.",
|
||||
"toastTitle": "{nodeName} 실패"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName}에 필수 입력이 누락되었습니다: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "필수 입력 슬롯에 연결이 없습니다.",
|
||||
"title": "연결 누락",
|
||||
"toastMessage": "{nodeName}에 필수 입력이 누락되었습니다: {inputName}",
|
||||
"toastTitle": "필수 입력 누락"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "모델 다시 로드 중...",
|
||||
"removeBackgroundImage": "배경 이미지 제거",
|
||||
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
|
||||
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
|
||||
"scene": "장면",
|
||||
"showGrid": "그리드 표시",
|
||||
"showSkeleton": "스켈레톤 표시",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "일반",
|
||||
"parameters": "파라미터",
|
||||
"pinned": "고정됨",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "서버로 전송된 워크플로우 데이터가 비어 있습니다. 이는 예기치 않은 시스템 오류일 수 있습니다."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "워크플로우에 결과를 생성할 출력 노드(예: 이미지 저장, 이미지 미리보기)가 포함되어 있지 않습니다."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 나중에 다시 시도해 주세요."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "서버에서 예기치 않은 오류가 발생했습니다. 서버 로그를 확인해 주세요."
|
||||
}
|
||||
},
|
||||
"properties": "속성",
|
||||
"removeFavorite": "즐겨찾기 해제",
|
||||
"resetAllParameters": "모든 매개변수 재설정",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Pausado",
|
||||
"resume": "Retomar download"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "entrada desconhecida",
|
||||
"nodeName": "Este nó"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Os dados do fluxo de trabalho enviados ao servidor estão vazios. Isso pode ser um erro inesperado do sistema."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "O fluxo de trabalho não contém nenhum nó de saída (ex: Salvar Imagem, Visualizar Imagem) para produzir um resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, tente novamente mais tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, verifique os logs do servidor."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Este nó gerou um erro durante a execução. Verifique suas entradas ou tente uma configuração diferente. Nenhum crédito foi cobrado.",
|
||||
"toastMessageLocal": "Este nó gerou um erro durante a execução. Verifique suas entradas ou tente uma configuração diferente.",
|
||||
"toastTitle": "{nodeName} falhou"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} está sem uma entrada obrigatória: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Entradas obrigatórias não possuem conexão.",
|
||||
"title": "Conexão ausente",
|
||||
"toastMessage": "{nodeName} está sem uma entrada obrigatória: {inputName}",
|
||||
"toastTitle": "Entrada obrigatória ausente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Recarregando modelo...",
|
||||
"removeBackgroundImage": "Remover Imagem de Fundo",
|
||||
"resizeNodeMatchOutput": "Redimensionar Nó para corresponder à saída",
|
||||
"retainViewOnReload": "Manter a visão da câmera ao recarregar o modelo",
|
||||
"scene": "Cena",
|
||||
"showGrid": "Mostrar Grade",
|
||||
"showSkeleton": "Mostrar Esqueleto",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Parâmetros",
|
||||
"pinned": "Fixado",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Os dados do fluxo de trabalho enviados ao servidor estão vazios. Isso pode ser um erro de sistema inesperado."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "O fluxo de trabalho não contém nenhum nó de saída (por exemplo, Salvar Imagem, Visualizar Imagem) para produzir um resultado."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, tente novamente mais tarde."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "O servidor encontrou um erro inesperado. Por favor, verifique os logs do servidor."
|
||||
}
|
||||
},
|
||||
"properties": "Propriedades",
|
||||
"removeFavorite": "Desfavoritar",
|
||||
"resetAllParameters": "Redefinir todos os parâmetros",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Приостановлено",
|
||||
"resume": "Возобновить загрузку"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "неизвестный вход",
|
||||
"nodeName": "Этот узел"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Данные рабочего процесса, отправленные на сервер, пусты. Это может быть неожиданной системной ошибкой."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "В рабочем процессе отсутствуют выходные узлы (например, Сохранить изображение, Просмотр изображения) для получения результата."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, попробуйте позже."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, проверьте журналы сервера."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Этот узел вызвал ошибку во время выполнения. Проверьте его входные данные или попробуйте другую конфигурацию. Кредиты не списаны.",
|
||||
"toastMessageLocal": "Этот узел вызвал ошибку во время выполнения. Проверьте его входные данные или попробуйте другую конфигурацию.",
|
||||
"toastTitle": "{nodeName} завершился с ошибкой"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} отсутствует обязательный вход: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Требуемые входные слоты не имеют подключений.",
|
||||
"title": "Отсутствует соединение",
|
||||
"toastMessage": "{nodeName} отсутствует обязательный вход: {inputName}",
|
||||
"toastTitle": "Отсутствует обязательный вход"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Перезагрузка модели...",
|
||||
"removeBackgroundImage": "Удалить фоновое изображение",
|
||||
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
|
||||
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
|
||||
"scene": "Сцена",
|
||||
"showGrid": "Показать сетку",
|
||||
"showSkeleton": "Показать скелет",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Обычный",
|
||||
"parameters": "Параметры",
|
||||
"pinned": "Закреплено",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Данные рабочего процесса, отправленные на сервер, пусты. Это может быть неожиданной системной ошибкой."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "В рабочем процессе отсутствуют выходные узлы (например, Сохранить изображение, Предпросмотр изображения) для получения результата."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, попробуйте позже."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "На сервере произошла непредвиденная ошибка. Пожалуйста, проверьте логи сервера."
|
||||
}
|
||||
},
|
||||
"properties": "Свойства",
|
||||
"removeFavorite": "Убрать из избранного",
|
||||
"resetAllParameters": "Сбросить все параметры",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "Duraklatıldı",
|
||||
"resume": "İndirmeye Devam Et"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "bilinmeyen giriş",
|
||||
"nodeName": "Bu düğüm"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Sunucuya gönderilen çalışma akışı verisi boş. Bu beklenmeyen bir sistem hatası olabilir."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Çalışma akışında sonuç üretecek herhangi bir çıktı düğümü (örn. Görseli Kaydet, Görseli Önizle) bulunmuyor."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Sunucuda beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Sunucuda beklenmeyen bir hata oluştu. Lütfen sunucu günlüklerini kontrol edin."
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "Bu düğüm çalıştırılırken bir hata oluştu. Girişlerini kontrol edin veya farklı bir yapılandırmayı deneyin. Kredi harcanmadı.",
|
||||
"toastMessageLocal": "Bu düğüm çalıştırılırken bir hata oluştu. Girişlerini kontrol edin veya farklı bir yapılandırmayı deneyin.",
|
||||
"toastTitle": "{nodeName} başarısız oldu"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} için gerekli bir giriş eksik: {inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "Gerekli giriş yuvalarına bağlantı yapılmamış.",
|
||||
"title": "Eksik bağlantı",
|
||||
"toastMessage": "{nodeName} için gerekli bir giriş eksik: {inputName}",
|
||||
"toastTitle": "Gerekli giriş eksik"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "Your account is not authorized for this feature.",
|
||||
"accessRestrictedTitle": "Access Restricted",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "Model yeniden yükleniyor...",
|
||||
"removeBackgroundImage": "Arka Plan Resmini Kaldır",
|
||||
"resizeNodeMatchOutput": "Düğümü çıktıya uyacak şekilde yeniden boyutlandır",
|
||||
"retainViewOnReload": "Model yeniden yüklendiğinde kamera görünümünü kilitle",
|
||||
"scene": "Sahne",
|
||||
"showGrid": "Izgarayı Göster",
|
||||
"showSkeleton": "İskeleti Göster",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "Normal",
|
||||
"parameters": "Parametreler",
|
||||
"pinned": "Sabitlendi",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "Sunucuya gönderilen çalışma akışı verisi boş. Bu beklenmeyen bir sistem hatası olabilir."
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "Çalışma akışında sonuç üretecek herhangi bir çıktı düğümü (ör. Görüntüyü Kaydet, Görüntüyü Önizle) bulunmuyor."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "Sunucu beklenmeyen bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin."
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "Sunucu beklenmeyen bir hata ile karşılaştı. Lütfen sunucu günlüklerini kontrol edin."
|
||||
}
|
||||
},
|
||||
"properties": "Özellikler",
|
||||
"removeFavorite": "Favorilerden Kaldır",
|
||||
"resetAllParameters": "Tüm parametreleri sıfırla",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "已暫停",
|
||||
"resume": "繼續下載"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "未知輸入",
|
||||
"nodeName": "此節點"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "傳送到伺服器的工作流程資料為空。這可能是系統的非預期錯誤。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流程中沒有包含任何輸出節點(例如:儲存圖像、預覽圖像),無法產生結果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "伺服器發生非預期錯誤。請稍後再試。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "伺服器發生非預期錯誤。請檢查伺服器日誌。"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "此節點在執行時發生錯誤。請檢查其輸入或嘗試其他設定。未扣除點數。",
|
||||
"toastMessageLocal": "此節點在執行時發生錯誤。請檢查其輸入或嘗試其他設定。",
|
||||
"toastTitle": "{nodeName} 執行失敗"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} 缺少必要的輸入:{inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "必要的輸入插槽沒有連接來源。",
|
||||
"title": "缺少連接",
|
||||
"toastMessage": "{nodeName} 缺少必要的輸入:{inputName}",
|
||||
"toastTitle": "缺少必要輸入"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "您的帳戶無權使用此功能。",
|
||||
"accessRestrictedTitle": "存取受限",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "重新載入模型中...",
|
||||
"removeBackgroundImage": "移除背景圖片",
|
||||
"resizeNodeMatchOutput": "調整節點以符合輸出",
|
||||
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
|
||||
"scene": "場景",
|
||||
"showGrid": "顯示格線",
|
||||
"showSkeleton": "顯示骨架",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "一般",
|
||||
"parameters": "參數",
|
||||
"pinned": "已釘選",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "傳送到伺服器的工作流程資料為空。這可能是系統發生了非預期的錯誤。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流程中沒有任何輸出節點(例如:儲存圖像、預覽圖像),無法產生結果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "伺服器發生非預期錯誤。請稍後再試。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "伺服器發生非預期錯誤。請檢查伺服器日誌。"
|
||||
}
|
||||
},
|
||||
"properties": "屬性",
|
||||
"removeFavorite": "取消收藏",
|
||||
"resetAllParameters": "重設所有參數",
|
||||
|
||||
@@ -911,6 +911,44 @@
|
||||
"paused": "已暂停",
|
||||
"resume": "恢复下载"
|
||||
},
|
||||
"errorCatalog": {
|
||||
"fallbacks": {
|
||||
"inputName": "未知输入",
|
||||
"nodeName": "此节点"
|
||||
},
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "发送到服务器的工作流数据为空。这可能是系统的意外错误。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流未包含任何输出节点(如保存图像、预览图像),无法生成结果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "服务器遇到意外错误。请稍后再试。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "服务器遇到意外错误。请检查服务器日志。"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastMessageCloud": "此节点在执行过程中发生错误。请检查其输入或尝试其他配置。未扣除积分。",
|
||||
"toastMessageLocal": "此节点在执行过程中发生错误。请检查其输入或尝试其他配置。",
|
||||
"toastTitle": "{nodeName} 执行失败"
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"details": "{nodeName} 缺少必需的输入:{inputName}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"message": "必需的输入插槽没有连接。",
|
||||
"title": "缺少连接",
|
||||
"toastMessage": "{nodeName} 缺少必需的输入:{inputName}",
|
||||
"toastTitle": "缺少必需输入"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorDialog": {
|
||||
"accessRestrictedMessage": "您的账户无权使用此功能。",
|
||||
"accessRestrictedTitle": "访问受限",
|
||||
@@ -1713,6 +1751,7 @@
|
||||
"reloadingModel": "正在重新加载模型...",
|
||||
"removeBackgroundImage": "移除背景图片",
|
||||
"resizeNodeMatchOutput": "调整节点以匹配输出",
|
||||
"retainViewOnReload": "模型重新加载时锁定相机视角",
|
||||
"scene": "场景",
|
||||
"showGrid": "显示网格",
|
||||
"showSkeleton": "显示骨架",
|
||||
@@ -2701,20 +2740,6 @@
|
||||
"normal": "正常",
|
||||
"parameters": "参数",
|
||||
"pinned": "顶固",
|
||||
"promptErrors": {
|
||||
"no_prompt": {
|
||||
"desc": "发送到服务器的工作流数据为空。这可能是一个意外的系统错误。"
|
||||
},
|
||||
"prompt_no_outputs": {
|
||||
"desc": "工作流中没有包含任何输出节点(例如:保存图像、预览图像),无法生成结果。"
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"desc": "服务器遇到意外错误。请稍后再试。"
|
||||
},
|
||||
"server_error_local": {
|
||||
"desc": "服务器遇到意外错误。请检查服务器日志。"
|
||||
}
|
||||
},
|
||||
"properties": "属性",
|
||||
"removeFavorite": "取消收藏",
|
||||
"resetAllParameters": "重置所有参数",
|
||||
|
||||
@@ -91,6 +91,16 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
],
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint'],
|
||||
|
||||
// ---- Qwen3 TTS (ComfyUI-FunBox) ----
|
||||
@@ -148,6 +158,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
],
|
||||
['checkpoints', 'DynamiCrafterModelLoader', 'ckpt_name'],
|
||||
['controlnet', 'DynamiCrafterCNLoader', 'ckpt_name'],
|
||||
|
||||
// ---- LayerStyle (ComfyUI_LayerStyle_Advance) ----
|
||||
['BEN', 'LS_LoadBenModel', 'model'],
|
||||
@@ -157,6 +169,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
|
||||
// ---- Inpaint (comfyui-inpaint-nodes) ----
|
||||
['inpaint', 'INPAINT_LoadInpaintModel', 'model_name'],
|
||||
['inpaint', 'INPAINT_LoadFooocusInpaint', 'head'],
|
||||
['inpaint', 'INPAINT_LoadFooocusInpaint', 'patch'],
|
||||
|
||||
// ---- LayerDiffuse (comfyui-layerdiffuse) ----
|
||||
['layer_model', 'LayeredDiffusionApply', 'config'],
|
||||
@@ -212,9 +226,15 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
|
||||
// ---- Mel-Band RoFormer audio separation (ComfyUI-MelBandRoFormer) ----
|
||||
['diffusion_models', 'MelBandRoFormerModelLoader', 'model_name'],
|
||||
|
||||
// ---- ComfyUI core geometry estimation (MoGe) ----
|
||||
['geometry_estimation', 'LoadMoGeModel', 'model_name'],
|
||||
|
||||
// ---- ComfyUI core optical flow (RAFT) ----
|
||||
['optical_flow', 'OpticalFlowLoader', 'model_name']
|
||||
['optical_flow', 'OpticalFlowLoader', 'model_name'],
|
||||
|
||||
// ---- WanVideo (ComfyUI-WanVideoWrapper) ----
|
||||
['loras', 'WanVideoLoraSelect', 'lora']
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>
|
||||
|
||||
@@ -7,31 +7,32 @@ import {
|
||||
import type { NodeValidationError } from './types'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
function requiredInputMissing(inputName?: string): NodeValidationError {
|
||||
return {
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: inputName ?? '',
|
||||
extra_info: inputName
|
||||
function nodeValidationError(
|
||||
type: string,
|
||||
inputName?: string,
|
||||
details = inputName ?? '',
|
||||
extraInfo: Record<string, unknown> = {}
|
||||
): NodeValidationError {
|
||||
const extra_info =
|
||||
inputName || Object.keys(extraInfo).length > 0
|
||||
? {
|
||||
input_name: inputName
|
||||
...(inputName ? { input_name: inputName } : {}),
|
||||
...extraInfo
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
type,
|
||||
message: 'Validation failed',
|
||||
details,
|
||||
extra_info
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeError() {
|
||||
function requiredInputMissing(inputName?: string): NodeValidationError {
|
||||
return {
|
||||
prompt_id: 'test',
|
||||
timestamp: Date.now(),
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'CUDA out of memory',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
...nodeValidationError('required_input_missing', inputName),
|
||||
message: 'Required input is missing'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('interpolates fallback templates when catalog keys are missing in the active locale', () => {
|
||||
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')
|
||||
|
||||
@@ -83,9 +84,12 @@ describe('errorMessageResolver', () => {
|
||||
nodeDisplayName: '0'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: '0 is missing a required input: seed',
|
||||
displayTitle: 'Required input is missing',
|
||||
displayMessage: 'Required input is missing',
|
||||
displayDetails: 'seed',
|
||||
displayItemLabel: '0 - seed',
|
||||
toastMessage: '0 is missing a required input: seed'
|
||||
toastTitle: 'Required input is missing',
|
||||
toastMessage: 'Required input is missing'
|
||||
})
|
||||
} finally {
|
||||
i18n.global.setLocaleMessage('ko', originalKoMessages)
|
||||
@@ -93,34 +97,348 @@ describe('errorMessageResolver', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves runtime errors with item labels and toast copy', () => {
|
||||
it.for([
|
||||
{
|
||||
type: 'bad_linked_input',
|
||||
inputName: 'model',
|
||||
expected: {
|
||||
catalogId: 'bad_linked_input',
|
||||
displayTitle: 'Invalid connection',
|
||||
displayMessage: 'A node connection could not be read correctly.',
|
||||
displayDetails: 'KSampler has an invalid connection for model.',
|
||||
displayItemLabel: 'KSampler - model',
|
||||
toastTitle: 'Invalid connection',
|
||||
toastMessage: 'KSampler has an invalid connection for model.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value_not_in_list',
|
||||
inputName: 'scheduler',
|
||||
expected: {
|
||||
catalogId: 'value_not_in_list',
|
||||
displayTitle: 'Invalid input',
|
||||
displayMessage: 'Some input values are not available for this node.',
|
||||
displayDetails: 'KSampler has an unsupported value for scheduler.',
|
||||
displayItemLabel: 'KSampler - scheduler',
|
||||
toastTitle: 'Invalid input',
|
||||
toastMessage: 'KSampler has an unsupported value for scheduler.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value_smaller_than_min',
|
||||
inputName: 'steps',
|
||||
expected: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
displayTitle: 'Input out of range',
|
||||
displayMessage: 'Some input values are outside the allowed range.',
|
||||
displayDetails: 'KSampler has a value below the minimum for steps.',
|
||||
displayItemLabel: 'KSampler - steps',
|
||||
toastTitle: 'Input out of range',
|
||||
toastMessage: 'KSampler has a value below the minimum for steps.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'return_type_mismatch',
|
||||
inputName: 'model',
|
||||
expected: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
displayTitle: 'Invalid connection',
|
||||
displayMessage:
|
||||
'Connected nodes are using incompatible input and output types.',
|
||||
displayDetails: 'KSampler has an incompatible connection for model.',
|
||||
displayItemLabel: 'KSampler - model',
|
||||
toastTitle: 'Invalid connection',
|
||||
toastMessage: 'KSampler has an incompatible connection for model.'
|
||||
}
|
||||
}
|
||||
])('resolves $type validation errors', ({ type, inputName, expected }) => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
isCloud: true,
|
||||
nodeDisplayName: 'KSampler',
|
||||
error: runtimeError()
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(type, inputName),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'execution_failed',
|
||||
displayItemLabel: 'KSampler',
|
||||
toastTitle: 'KSampler failed',
|
||||
).toEqual(expected)
|
||||
})
|
||||
|
||||
it('includes received values in validation range and option details', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'return_type_mismatch',
|
||||
'images',
|
||||
'images, received_type(LATENT) mismatch input_type(IMAGE)',
|
||||
{
|
||||
input_config: ['IMAGE', {}],
|
||||
received_type: 'LATENT'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"Preview Image's images input expects IMAGE, but the connected output is LATENT.",
|
||||
toastMessage:
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
|
||||
"Preview Image's images input expects IMAGE, but the connected output is LATENT."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'invalid_input_type',
|
||||
'steps',
|
||||
"steps, abc, invalid literal for int() with base 10: 'abc'",
|
||||
{
|
||||
input_config: ['INT', {}],
|
||||
received_value: 'abc'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value abc for KSampler's steps couldn't be converted to INT.",
|
||||
toastMessage:
|
||||
"The value abc for KSampler's steps couldn't be converted to INT."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError('value_smaller_than_min', 'steps', 'steps', {
|
||||
input_config: ['INT', { min: 1 }],
|
||||
received_value: 0
|
||||
}),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value 0 for KSampler's steps is below the minimum 1.",
|
||||
toastMessage: "The value 0 for KSampler's steps is below the minimum 1."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError('value_bigger_than_max', 'cfg', 'cfg', {
|
||||
input_config: ['FLOAT', { max: 30 }],
|
||||
received_value: 40
|
||||
}),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value 40 for KSampler's cfg is above the maximum 30.",
|
||||
toastMessage: "The value 40 for KSampler's cfg is above the maximum 30."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'value_not_in_list',
|
||||
'scheduler',
|
||||
'scheduler',
|
||||
{
|
||||
received_value: 'not-a-scheduler'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value not-a-scheduler for KSampler's scheduler is not available.",
|
||||
toastMessage:
|
||||
"The value not-a-scheduler for KSampler's scheduler is not available."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves local runtime errors without cloud credit copy', () => {
|
||||
it('falls back to generic copy when structured values cannot be formatted', () => {
|
||||
const circularValue: Record<string, unknown> = {}
|
||||
circularValue.self = circularValue
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
isCloud: false,
|
||||
nodeDisplayName: 'KSampler',
|
||||
error: runtimeError()
|
||||
}).toastMessage
|
||||
).toBe(
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration.'
|
||||
)
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'invalid_input_type',
|
||||
'steps',
|
||||
"steps, [object Object], invalid literal for int() with base 10: 'abc'",
|
||||
{
|
||||
input_config: ['INT', {}],
|
||||
received_value: circularValue
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: "KSampler couldn't convert steps to the expected type.",
|
||||
toastMessage: "KSampler couldn't convert steps to the expected type."
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw details when validation itself fails unexpectedly', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_inner_validation',
|
||||
'images',
|
||||
'list index out of range'
|
||||
),
|
||||
nodeDisplayName: 'Image Scale'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Validation failed',
|
||||
displayMessage: "The workflow couldn't validate a connected node.",
|
||||
displayDetails:
|
||||
"Image Scale couldn't validate images: list index out of range",
|
||||
displayItemLabel: 'Image Scale - images',
|
||||
toastTitle: 'Validation failed',
|
||||
toastMessage:
|
||||
"Image Scale couldn't validate images: list index out of range"
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_validation',
|
||||
undefined,
|
||||
'tuple index out of range'
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Validation failed',
|
||||
displayMessage:
|
||||
'The workflow could not be validated because a node validation check failed unexpectedly.',
|
||||
displayDetails:
|
||||
'Preview Image failed during validation: tuple index out of range',
|
||||
displayItemLabel: 'Preview Image',
|
||||
toastTitle: 'Validation failed',
|
||||
toastMessage:
|
||||
'Preview Image failed during validation: tuple index out of range'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_validation',
|
||||
undefined,
|
||||
''
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: 'Preview Image failed during validation.',
|
||||
toastMessage: 'Preview Image failed during validation.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves custom validation image failures as image-not-loaded copy', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'image',
|
||||
'image - Invalid image file: broken.png'
|
||||
),
|
||||
nodeDisplayName: 'Load Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image.",
|
||||
displayDetails:
|
||||
"The image for Load Image couldn't be loaded. Try adding it again.",
|
||||
displayItemLabel: 'Load Image',
|
||||
toastTitle: "Input image couldn't be loaded"
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'image',
|
||||
"[Errno 21] Is a directory: '/app/comfyui/input'"
|
||||
),
|
||||
nodeDisplayName: 'Load Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image.",
|
||||
displayItemLabel: 'Load Image'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw details for generic custom validation failures', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'setting',
|
||||
'setting - Unsupported lab value: bad-value'
|
||||
),
|
||||
nodeDisplayName: 'Custom Validation Error'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'custom_validation_failed',
|
||||
displayTitle: 'Invalid input',
|
||||
displayMessage: 'A node rejected one or more input values.',
|
||||
displayDetails:
|
||||
'Custom Validation Error failed custom validation: setting - Unsupported lab value: bad-value',
|
||||
displayItemLabel: 'Custom Validation Error - setting',
|
||||
toastTitle: 'Invalid input',
|
||||
toastMessage: 'Custom Validation Error rejected the value for setting.'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat raw details as the input name when input metadata is missing', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
undefined,
|
||||
'Traceback line 1\nTraceback line 2'
|
||||
),
|
||||
nodeDisplayName: 'Custom Validation Error'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayItemLabel: 'Custom Validation Error - unknown input',
|
||||
toastMessage:
|
||||
'Custom Validation Error rejected the value for unknown input.'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw cycle paths for dependency cycle details', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'dependency_cycle',
|
||||
undefined,
|
||||
'7 (ImageScale) -> 7 (ImageScale)'
|
||||
),
|
||||
nodeDisplayName: 'Image Scale'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Invalid workflow',
|
||||
displayMessage: 'The workflow has a circular node connection.',
|
||||
displayDetails:
|
||||
'Image Scale is part of a circular connection: 7 (ImageScale) to 7 (ImageScale)',
|
||||
displayItemLabel: 'Image Scale',
|
||||
toastTitle: 'Invalid workflow',
|
||||
toastMessage: 'Image Scale is part of a circular connection.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves known prompt errors with run error rules', () => {
|
||||
@@ -135,6 +453,7 @@ describe('errorMessageResolver', () => {
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Prompt has no outputs',
|
||||
displayMessage:
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
})
|
||||
@@ -182,6 +501,91 @@ describe('errorMessageResolver', () => {
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves newly cataloged prompt-level errors', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'missing_node_type',
|
||||
message:
|
||||
"Node 'ID #4' has no class_type. The workflow may be corrupted or a custom node is missing.",
|
||||
details: "Node ID '#4'"
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Missing node type',
|
||||
displayMessage:
|
||||
'A node type is missing or unavailable. The workflow may be corrupted or require a custom node.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'OOMError',
|
||||
message: 'OOMError: Workflow execution failed',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'out_of_memory',
|
||||
displayTitle: 'Generation failed',
|
||||
displayMessage:
|
||||
'Not enough GPU memory. Try reducing complexity and run again. No credits charged.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: false,
|
||||
error: {
|
||||
type: 'OOMError',
|
||||
message: 'OOMError: Workflow execution failed',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'out_of_memory',
|
||||
displayTitle: 'Generation failed',
|
||||
displayMessage:
|
||||
'Not enough GPU memory. Try reducing complexity and run again.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'ImageDownloadError',
|
||||
message: 'ImageDownloadError: Failed to validate images',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'prompt_outputs_failed_validation',
|
||||
message: 'Prompt outputs failed validation',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Prompt validation failed',
|
||||
displayMessage:
|
||||
'The workflow has invalid node inputs. Fix the highlighted nodes before running it again.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves missing error group display copy', () => {
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
|
||||
@@ -9,22 +9,33 @@ import { st, t, te } from '@/i18n'
|
||||
|
||||
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
|
||||
const REQUIRED_INPUT_MISSING_CATALOG_ID = 'missing_connection'
|
||||
const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
|
||||
const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
|
||||
const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'prompt_no_outputs',
|
||||
'no_prompt',
|
||||
'server_error'
|
||||
'server_error',
|
||||
'missing_node_type',
|
||||
'prompt_outputs_failed_validation'
|
||||
])
|
||||
|
||||
interface ValidationCatalogRule {
|
||||
catalogId: string
|
||||
itemLabel: 'node' | 'nodeInput'
|
||||
copyKeys?: CopyKeys
|
||||
}
|
||||
|
||||
interface ErrorResolveContext {
|
||||
isCloud?: boolean
|
||||
nodeDisplayName?: string
|
||||
}
|
||||
|
||||
type CatalogParams = Record<string, string | number>
|
||||
|
||||
function translateCatalogMessage(
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, string | number>
|
||||
params?: CatalogParams
|
||||
): string {
|
||||
if (te(key)) return params ? t(key, params) : t(key)
|
||||
if (!params) return fallback
|
||||
@@ -34,6 +45,15 @@ function translateCatalogMessage(
|
||||
)
|
||||
}
|
||||
|
||||
function translateOptionalCatalogMessage(
|
||||
key: string,
|
||||
fallback?: string,
|
||||
params?: CatalogParams
|
||||
): string | undefined {
|
||||
if (te(key)) return params ? t(key, params) : t(key)
|
||||
return fallback?.trim() ? fallback : undefined
|
||||
}
|
||||
|
||||
function normalizeNodeName(nodeDisplayName: string | undefined): string {
|
||||
return (
|
||||
nodeDisplayName?.trim() ||
|
||||
@@ -42,99 +62,362 @@ function normalizeNodeName(nodeDisplayName: string | undefined): string {
|
||||
}
|
||||
|
||||
function getInputName(error: NodeValidationError): string {
|
||||
const inputName = error.extra_info?.input_name ?? error.details
|
||||
const inputName = error.extra_info?.input_name
|
||||
return (
|
||||
inputName?.trim() ||
|
||||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
|
||||
)
|
||||
}
|
||||
|
||||
function isRequiredInputMissing(
|
||||
error: NodeValidationError
|
||||
): error is NodeValidationError & { type: typeof REQUIRED_INPUT_MISSING_TYPE } {
|
||||
return error.type === REQUIRED_INPUT_MISSING_TYPE
|
||||
function getErrorText(error: NodeValidationError) {
|
||||
return [
|
||||
'message' in error ? error.message : undefined,
|
||||
'details' in error ? error.details : undefined
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function isImageNotLoadedText(text: string): boolean {
|
||||
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
|
||||
}
|
||||
|
||||
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
|
||||
return (
|
||||
error.type === 'custom_validation_failed' &&
|
||||
isImageNotLoadedText(getErrorText(error))
|
||||
)
|
||||
}
|
||||
|
||||
function nodeInputItemLabel(nodeName: string, inputName: string): string {
|
||||
return `${nodeName} - ${inputName}`
|
||||
}
|
||||
|
||||
function formatDependencyCycleDetails(details: string): string {
|
||||
// Core reports dependency cycle paths as "node -> node"; catalog copy embeds
|
||||
// those paths in prose, where "to" reads more naturally.
|
||||
return details.replace(/\s*->\s*/g, ' to ')
|
||||
}
|
||||
|
||||
function formatCatalogValue(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getInputConfigValue(
|
||||
error: NodeValidationError,
|
||||
key: 'min' | 'max'
|
||||
): string | undefined {
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
const config = inputConfig[1]
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
|
||||
return formatCatalogValue((config as Record<string, unknown>)[key])
|
||||
}
|
||||
|
||||
function getInputConfigType(error: NodeValidationError): string | undefined {
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
return formatCatalogValue(inputConfig[0])
|
||||
}
|
||||
|
||||
function getValidationParams(
|
||||
error: NodeValidationError,
|
||||
nodeName: string,
|
||||
inputName: string
|
||||
): CatalogParams {
|
||||
const params: CatalogParams = { nodeName, inputName }
|
||||
const receivedValue = formatCatalogValue(error.extra_info?.received_value)
|
||||
const receivedType = formatCatalogValue(error.extra_info?.received_type)
|
||||
const expectedType = getInputConfigType(error)
|
||||
const minValue = getInputConfigValue(error, 'min')
|
||||
const maxValue = getInputConfigValue(error, 'max')
|
||||
|
||||
if (receivedValue !== undefined) params.receivedValue = receivedValue
|
||||
if (receivedType !== undefined) params.receivedType = receivedType
|
||||
if (expectedType !== undefined) params.expectedType = expectedType
|
||||
if (minValue !== undefined) params.minValue = minValue
|
||||
if (maxValue !== undefined) params.maxValue = maxValue
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function hasParams(params: CatalogParams, keys: string[]): boolean {
|
||||
return keys.every((key) => params[key] !== undefined)
|
||||
}
|
||||
|
||||
interface CopyKeys {
|
||||
detailsKey: string
|
||||
toastMessageKey: string
|
||||
}
|
||||
|
||||
const DEFAULT_COPY_KEYS: CopyKeys = {
|
||||
detailsKey: 'details',
|
||||
toastMessageKey: 'toastMessage'
|
||||
}
|
||||
|
||||
const VALUE_SPECIFIC_COPY_RULES: Record<
|
||||
string,
|
||||
{
|
||||
requiredParams: string[]
|
||||
suffix: 'WithTypes' | 'WithValue'
|
||||
}
|
||||
> = {
|
||||
return_type_mismatch: {
|
||||
requiredParams: ['expectedType', 'receivedType'],
|
||||
suffix: 'WithTypes'
|
||||
},
|
||||
invalid_input_type: {
|
||||
requiredParams: ['receivedValue', 'expectedType'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
requiredParams: ['receivedValue', 'minValue'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
requiredParams: ['receivedValue', 'maxValue'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_not_in_list: {
|
||||
requiredParams: ['receivedValue'],
|
||||
suffix: 'WithValue'
|
||||
}
|
||||
}
|
||||
|
||||
function getValueSpecificCopyKeys(
|
||||
errorType: string,
|
||||
params: CatalogParams
|
||||
): CopyKeys {
|
||||
const rule = VALUE_SPECIFIC_COPY_RULES[errorType]
|
||||
if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS
|
||||
|
||||
return {
|
||||
detailsKey: `details${rule.suffix}`,
|
||||
toastMessageKey: `toastMessage${rule.suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
return error.details.trim()
|
||||
? {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
toastMessageKey: 'toastMessageWithRawDetails'
|
||||
}
|
||||
: DEFAULT_COPY_KEYS
|
||||
}
|
||||
|
||||
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
if (!error.details.trim()) return DEFAULT_COPY_KEYS
|
||||
|
||||
return {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
toastMessageKey: 'toastMessage'
|
||||
}
|
||||
}
|
||||
|
||||
function getValidationCopyKeys(
|
||||
error: NodeValidationError,
|
||||
params: CatalogParams
|
||||
): CopyKeys {
|
||||
if (error.type === 'exception_during_validation') {
|
||||
return getRawDetailsCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'exception_during_inner_validation') {
|
||||
return getRawDetailsCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'custom_validation_failed') {
|
||||
return getRawDetailsOnlyCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'dependency_cycle') {
|
||||
return getRawDetailsOnlyCopyKeys(error)
|
||||
}
|
||||
|
||||
return getValueSpecificCopyKeys(error.type, params)
|
||||
}
|
||||
|
||||
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
|
||||
[REQUIRED_INPUT_MISSING_TYPE]: {
|
||||
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
bad_linked_input: {
|
||||
catalogId: 'bad_linked_input',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
return_type_mismatch: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
invalid_input_type: {
|
||||
catalogId: 'invalid_input_type',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
catalogId: 'value_bigger_than_max',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_not_in_list: {
|
||||
catalogId: 'value_not_in_list',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
custom_validation_failed: {
|
||||
catalogId: 'custom_validation_failed',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_inner_validation: {
|
||||
catalogId: 'exception_during_inner_validation',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_validation: {
|
||||
catalogId: 'exception_during_validation',
|
||||
itemLabel: 'node'
|
||||
},
|
||||
dependency_cycle: {
|
||||
catalogId: 'dependency_cycle',
|
||||
itemLabel: 'node'
|
||||
}
|
||||
}
|
||||
|
||||
// Image-not-loaded shares the custom_validation_failed type, so it needs a
|
||||
// predicate override to use image_not_loaded locale copy and default copy keys.
|
||||
const IMAGE_NOT_LOADED_VALIDATION_RULE = {
|
||||
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
|
||||
itemLabel: 'node',
|
||||
copyKeys: DEFAULT_COPY_KEYS
|
||||
} satisfies ValidationCatalogRule
|
||||
|
||||
function resolveValidationCatalogCopy(
|
||||
error: NodeValidationError,
|
||||
context: ErrorResolveContext,
|
||||
localeKey: string,
|
||||
rule: ValidationCatalogRule
|
||||
): ResolvedErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const trimmedDetails = error.details.trim()
|
||||
const rawDetails =
|
||||
error.type === 'dependency_cycle'
|
||||
? formatDependencyCycleDetails(trimmedDetails)
|
||||
: trimmedDetails
|
||||
const params = {
|
||||
...getValidationParams(error, nodeName, inputName),
|
||||
rawDetails
|
||||
}
|
||||
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
|
||||
const titleFallback = error.message || error.type
|
||||
const itemLabelFallback =
|
||||
rule.itemLabel === 'node'
|
||||
? nodeName
|
||||
: nodeInputItemLabel(nodeName, inputName)
|
||||
const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params)
|
||||
|
||||
return {
|
||||
catalogId: rule.catalogId,
|
||||
displayTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.title`,
|
||||
titleFallback,
|
||||
params
|
||||
),
|
||||
displayMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.message`,
|
||||
error.message,
|
||||
params
|
||||
),
|
||||
displayDetails: translateOptionalCatalogMessage(
|
||||
`${keyPrefix}.${copyKeys.detailsKey}`,
|
||||
error.details,
|
||||
params
|
||||
),
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
itemLabelFallback,
|
||||
params
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
titleFallback,
|
||||
params
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.${copyKeys.toastMessageKey}`,
|
||||
error.message,
|
||||
params
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeValidationErrorMessage(
|
||||
error: NodeValidationError,
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
if (!isRequiredInputMissing(error)) return {}
|
||||
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const keyPrefix = 'errorCatalog.validationErrors.required_input_missing'
|
||||
|
||||
return {
|
||||
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
|
||||
displayTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.title`,
|
||||
'Missing connection'
|
||||
),
|
||||
displayMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.message`,
|
||||
'Required input slots have no connection feeding them.'
|
||||
),
|
||||
displayDetails: translateCatalogMessage(
|
||||
`${keyPrefix}.details`,
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
{ nodeName, inputName }
|
||||
),
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
'{nodeName} - {inputName}',
|
||||
{ nodeName, inputName }
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
'Required input missing'
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.toastMessage`,
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
{ nodeName, inputName }
|
||||
if (isImageNotLoadedValidationError(error)) {
|
||||
return resolveValidationCatalogCopy(
|
||||
error,
|
||||
context,
|
||||
'image_not_loaded',
|
||||
IMAGE_NOT_LOADED_VALIDATION_RULE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutionErrorMessage(
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const keyPrefix = 'errorCatalog.runtimeErrors.execution_failed'
|
||||
const toastMessageKey = context.isCloud
|
||||
? `${keyPrefix}.toastMessageCloud`
|
||||
: `${keyPrefix}.toastMessageLocal`
|
||||
const toastMessageFallback = context.isCloud
|
||||
? 'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
|
||||
: 'This node threw an error during execution. Check its inputs or try a different configuration.'
|
||||
const rule = VALIDATION_ERROR_RULES[error.type]
|
||||
if (!rule) return {}
|
||||
|
||||
return {
|
||||
catalogId: EXECUTION_FAILED_CATALOG_ID,
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
nodeName,
|
||||
{
|
||||
nodeName
|
||||
}
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
'{nodeName} failed',
|
||||
{ nodeName }
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
toastMessageKey,
|
||||
toastMessageFallback,
|
||||
{ nodeName }
|
||||
)
|
||||
}
|
||||
return resolveValidationCatalogCopy(error, context, error.type, rule)
|
||||
}
|
||||
|
||||
function resolvePromptErrorMessage(
|
||||
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
if (error.type === 'ImageDownloadError') {
|
||||
return {
|
||||
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
|
||||
displayTitle: st(
|
||||
'errorCatalog.promptErrors.image_not_loaded.title',
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(
|
||||
'errorCatalog.promptErrors.image_not_loaded.desc',
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (error.type === 'OOMError') {
|
||||
const messageKey = context.isCloud
|
||||
? 'errorCatalog.promptErrors.out_of_memory.descCloud'
|
||||
: 'errorCatalog.promptErrors.out_of_memory.descLocal'
|
||||
|
||||
return {
|
||||
catalogId: OUT_OF_MEMORY_CATALOG_ID,
|
||||
displayTitle: st(
|
||||
'errorCatalog.promptErrors.out_of_memory.title',
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(messageKey, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
|
||||
|
||||
const errorTypeKey =
|
||||
@@ -145,6 +428,10 @@ function resolvePromptErrorMessage(
|
||||
: error.type
|
||||
|
||||
return {
|
||||
displayTitle: translateCatalogMessage(
|
||||
`errorCatalog.promptErrors.${errorTypeKey}.title`,
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(
|
||||
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
|
||||
error.message
|
||||
@@ -231,11 +518,6 @@ export function resolveRunErrorMessage(
|
||||
return resolveNodeValidationErrorMessage(source.error, {
|
||||
nodeDisplayName: source.nodeDisplayName
|
||||
})
|
||||
case 'execution':
|
||||
return resolveExecutionErrorMessage({
|
||||
isCloud: source.isCloud,
|
||||
nodeDisplayName: source.nodeDisplayName
|
||||
})
|
||||
case 'prompt':
|
||||
return resolvePromptErrorMessage(source.error, {
|
||||
isCloud: source.isCloud
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { NodeError, PromptError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
@@ -39,12 +35,6 @@ export type RunErrorMessageSource =
|
||||
error: NodeValidationError
|
||||
nodeDisplayName: string
|
||||
}
|
||||
| {
|
||||
kind: 'execution'
|
||||
error: ExecutionErrorWsMessage
|
||||
nodeDisplayName?: string
|
||||
isCloud: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'prompt'
|
||||
error: PromptError
|
||||
|
||||
@@ -7,30 +7,32 @@
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
v-if="viewMode === 'grid'"
|
||||
ref="gridEl"
|
||||
data-testid="image-grid"
|
||||
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
|
||||
class="relative grid w-full flex-1 gap-1 rounded-sm p-1 contain-size"
|
||||
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
v-for="(url, index) in imageUrls"
|
||||
:key="index"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
|
||||
size="unset"
|
||||
class="ring-ring overflow-hidden rounded-none p-0 hover:ring-1 focus-visible:ring-2"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@pointerdown="trackPointerStart"
|
||||
@click="handleGridThumbnailClick($event, index)"
|
||||
@click="openImageInGallery(index)"
|
||||
>
|
||||
<img
|
||||
:src="url"
|
||||
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
|
||||
draggable="false"
|
||||
class="pointer-events-none size-full object-contain"
|
||||
@load="updateAspectRatio($event, index)"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Gallery View (Image Wrapper) -->
|
||||
@@ -167,11 +169,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useElementSize, useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -202,12 +205,17 @@ function defaultViewMode(urls: readonly string[]): ViewMode {
|
||||
return urls.length > 1 ? 'grid' : 'gallery'
|
||||
}
|
||||
|
||||
const { width: gridWidth, height: gridHeight } = useElementSize(
|
||||
useTemplateRef('gridEl')
|
||||
)
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
|
||||
const galleryPanelEl = ref<HTMLDivElement>()
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const showLoader = ref(false)
|
||||
const imageAspectRatio = ref(1)
|
||||
|
||||
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
() => {
|
||||
@@ -227,10 +235,8 @@ const imageAltText = computed(() =>
|
||||
})
|
||||
)
|
||||
const gridCols = computed(() => {
|
||||
const count = imageUrls.length
|
||||
if (count <= 4) return 2
|
||||
if (count <= 9) return 3
|
||||
return 4
|
||||
const bias = gridWidth.value / gridHeight.value / imageAspectRatio.value
|
||||
return Math.max(Math.round(Math.sqrt(imageUrls.length * bias)), 1)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -274,6 +280,14 @@ function handleImageLoad(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateAspectRatio(event: Event, index: number) {
|
||||
if (!(event.target instanceof HTMLImageElement) || index !== 0) return
|
||||
const { naturalWidth, naturalHeight } = event.target
|
||||
if (naturalWidth && naturalHeight) {
|
||||
imageAspectRatio.value = naturalWidth / naturalHeight
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
@@ -310,20 +324,6 @@ function setCurrentIndex(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const CLICK_THRESHOLD = 3
|
||||
let pointerStartPos = { x: 0, y: 0 }
|
||||
|
||||
function trackPointerStart(event: PointerEvent) {
|
||||
pointerStartPos = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function handleGridThumbnailClick(event: MouseEvent, index: number) {
|
||||
const dx = event.clientX - pointerStartPos.x
|
||||
const dy = event.clientY - pointerStartPos.y
|
||||
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
|
||||
openImageInGallery(index)
|
||||
}
|
||||
|
||||
async function openImageInGallery(index: number) {
|
||||
setCurrentIndex(index)
|
||||
viewMode.value = 'gallery'
|
||||
|
||||
@@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => {
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey when no nodeId is available at all', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(
|
||||
widget,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
@@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses duplicate normal widgets on the same node to one render', () => {
|
||||
const colorA = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const colorB = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'ColorToRGBInt',
|
||||
widgets: [colorA, colorB],
|
||||
title: 'Color to RGB Int',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('color')
|
||||
expect(result[0].renderKey).toBe('node:1:color:color:color')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
|
||||
@@ -129,11 +129,15 @@ export function getWidgetIdentity(
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const hostNodeIdRoot =
|
||||
nodeId !== undefined && nodeId !== ''
|
||||
? `node:${String(stripGraphPrefix(nodeId))}`
|
||||
: undefined
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
: hostNodeIdRoot
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IColorWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
const widgets: IColorWidget[] = []
|
||||
const addWidget = vi.fn(
|
||||
(
|
||||
type: string,
|
||||
name: string,
|
||||
value: string,
|
||||
_callback: () => void,
|
||||
options: IWidgetOptions
|
||||
) => {
|
||||
const widget = {
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
callback: _callback
|
||||
} as unknown as IColorWidget
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}
|
||||
)
|
||||
|
||||
return { widgets, addWidget } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
const colorSpec: InputSpec = {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
default: '#ffffff',
|
||||
socketless: true
|
||||
}
|
||||
|
||||
describe('useColorWidget', () => {
|
||||
it('reads the top-level default from the V2 spec', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, colorSpec)
|
||||
expect(widget.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('falls back to nested options.default when top-level default is absent', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
options: { default: '#abcdef' }
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#abcdef')
|
||||
})
|
||||
|
||||
it('falls back to #000000 when no default is declared', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color'
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#000000')
|
||||
})
|
||||
|
||||
it('returns the existing widget instead of creating a duplicate', () => {
|
||||
const node = createMockNode()
|
||||
const first = useColorWidget()(node, colorSpec)
|
||||
const second = useColorWidget()(node, colorSpec)
|
||||
expect(second).toBe(first)
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
|
||||
const { name, options } = inputSpec as ColorInputSpec
|
||||
const defaultValue = options?.default || '#000000'
|
||||
const colorSpec = inputSpec as ColorInputSpec
|
||||
const { name, options } = colorSpec
|
||||
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
|
||||
|
||||
const existing = node.widgets?.find(
|
||||
(w): w is IColorWidget => w.name === name && w.type === 'color'
|
||||
)
|
||||
if (existing) return existing
|
||||
|
||||
const widget = node.addWidget('color', name, defaultValue, () => {}, {
|
||||
serialize: true
|
||||
|
||||
@@ -473,6 +473,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
node.imgs = [element]
|
||||
node.imageIndex = activeIndex
|
||||
|
||||
const outputs = getNodeOutputs(node)
|
||||
if (outputs?.images) node.images = outputs.images
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user