mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
8 Commits
codex/e2e-
...
v1.47.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d677cc6404 | ||
|
|
b0f2595efe | ||
|
|
6ec75d6985 | ||
|
|
7cc27b07fa | ||
|
|
51ea158b15 | ||
|
|
0cb2b61ece | ||
|
|
8529adfeb1 | ||
|
|
5374aa7ee1 |
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
* add a provider key, see it listed, delete it — the full CRUD round-trip —
|
||||
* plus the entitlement contract that a non-entitled account never sees the
|
||||
* gated providers.
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// --- ADD -------------------------------------------------------------
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
// Pick the entitled Runway provider from the server-driven dropdown.
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
await page.getByRole('option', { name: 'Runway' }).click()
|
||||
|
||||
await formDialog.locator('#secret-name').fill('My Runway Key')
|
||||
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
|
||||
|
||||
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(formDialog).toBeHidden()
|
||||
|
||||
// --- LIST ------------------------------------------------------------
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
|
||||
|
||||
// The create request carried the plaintext value + provider...
|
||||
expect(backend.createRequests).toHaveLength(1)
|
||||
expect(backend.createRequests[0]).toMatchObject({
|
||||
name: 'My Runway Key',
|
||||
provider: 'runway',
|
||||
secret_value: RUNWAY_KEY_VALUE
|
||||
})
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
const confirmDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Delete Secret' })
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
expect(backend.store).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
await mockSecretsBackend(page, [])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
// providers must not appear anywhere.
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
// Anchor on the opened listbox so the absence assertions below can't pass
|
||||
// vacuously against a dropdown that never opened.
|
||||
const providerListbox = page.getByRole('listbox')
|
||||
await expect(providerListbox).toBeVisible()
|
||||
// An empty allowlist must yield an empty dropdown. Asserting zero options
|
||||
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
|
||||
// where `availableProviders` is null and the default providers would show.
|
||||
await expect(providerListbox.getByRole('option')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,13 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
|
||||
wstest(
|
||||
'restoring workflow restores state',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
|
||||
const preview = node.root.locator('textarea')
|
||||
|
||||
await test.step('node previews execution result', async () => {
|
||||
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
|
||||
execution.executed('', id, { text: 'massive fennec ears' })
|
||||
await expect(preview).toHaveValue('massive fennec ears')
|
||||
})
|
||||
|
||||
await test.step('swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(node.root).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await expect(node.root).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(preview, 'previous output is restored').toHaveValue(
|
||||
'massive fennec ears'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const SLOT_ERROR_CLASS = /before:ring-error/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
@@ -69,6 +70,25 @@ async function selectLoadImageNodeForPaste(
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function getInputSlotIndexByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
inputName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
({ inputName, nodeId }) => {
|
||||
const graph = window.app!.canvas.graph ?? window.app!.graph
|
||||
const node = graph.getNodeById(nodeId)
|
||||
const index = node?.findInputSlot(inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ inputName, nodeId: toNodeId(nodeId) }
|
||||
)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
@@ -139,17 +159,10 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
|
||||
const modelInputIndex = await comfyPage.page.evaluate(
|
||||
({ nodeId, inputName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const index =
|
||||
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
const modelInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
ksamplerId,
|
||||
KSAMPLER_MODEL_INPUT_NAME
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
@@ -175,7 +188,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(modelInputSlotRow).toBeVisible()
|
||||
await expect(modelInputSlotRow).toBeInViewport()
|
||||
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
|
||||
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
@@ -407,5 +420,76 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('boundary-linked validation error surfaces on the subgraph host', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
const hostInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
subgraphParentId,
|
||||
'positive'
|
||||
)
|
||||
const hostInputSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
subgraphParentId,
|
||||
hostInputIndex
|
||||
)
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host must mount before injecting validation errors'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host should start without an error ring'
|
||||
).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
await test.step('surface the boundary-linked error on the host', async () => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[INNER_EXECUTION_ID]: buildKSamplerError(
|
||||
'required_input_missing',
|
||||
'positive',
|
||||
'Required input is missing: positive'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('confirm the interior node does not show the surfaced ring', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
const interiorKSamplerId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const interiorPositiveInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
interiorKSamplerId,
|
||||
'positive'
|
||||
)
|
||||
const interiorPositiveSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
interiorKSamplerId,
|
||||
interiorPositiveInputIndex
|
||||
)
|
||||
const interiorInnerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
|
||||
|
||||
await expect(interiorInnerWrapper).toBeVisible()
|
||||
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
await expect(interiorPositiveSlotHighlight).toBeVisible()
|
||||
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
|
||||
SLOT_ERROR_CLASS
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.7",
|
||||
"version": "1.47.8",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -477,4 +478,49 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
@@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
dataType
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
@@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name = input.name
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name = output.name in allDataTypesLocale ? undefined : output.name
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: nodeDef.display_name ?? nodeDef.name,
|
||||
description: nodeDef.description || undefined,
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
@@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [normalizeI18nKey(category), category])
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -4,37 +4,67 @@
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-pressed="grid"
|
||||
:class="
|
||||
cn(
|
||||
actionBtnClass,
|
||||
grid && 'bg-component-node-widget-background-selected'
|
||||
)
|
||||
"
|
||||
@click="grid = !grid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('boundingBoxes.grid') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="cn(actionBtnClass, 'ml-auto')"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<span>{{ $t('boundingBoxes.clearAll') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -122,16 +152,6 @@
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -147,6 +167,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const actionBtnClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
@@ -172,7 +195,8 @@ const {
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
syncState,
|
||||
grid
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
|
||||
@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
|
||||
@@ -449,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@focus-outside="
|
||||
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
|
||||
@@ -53,7 +53,22 @@ export function onRekaPointerDownOutside(
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
//
|
||||
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
|
||||
// also lose focus to an ordinary app element — not just a portal — when a
|
||||
// nested dialog closes and the element it focused was removed (deleting the
|
||||
// selected row). That programmatic focus shift is not a dismiss intent, so such
|
||||
// a dialog opts out of focus-outside dismissal entirely via
|
||||
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
|
||||
// pointer.
|
||||
export function onRekaFocusOutside(
|
||||
event: OutsideEvent,
|
||||
options: { dismissOnFocusOutside?: boolean } = {}
|
||||
) {
|
||||
if (options.dismissOnFocusOutside === false) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await userEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
it('opens the color picker when a swatch is clicked', async () => {
|
||||
const { container } = renderRow(['#ff0000'])
|
||||
const swatch = container.querySelector('[data-index="0"]')!
|
||||
await userEvent.click(swatch)
|
||||
expect(swatch.getAttribute('data-state')).toBe('open')
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
<ColorPicker
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
:key="i"
|
||||
:model-value="hex"
|
||||
:alpha="false"
|
||||
@update:model-value="(value) => updateAt(i, value)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ColorPicker>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
@@ -21,12 +29,6 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +36,7 @@
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
@@ -41,8 +44,9 @@ const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: true,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
@@ -127,6 +129,8 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
getExecutionIdByNode,
|
||||
getNodeByExecutionId
|
||||
@@ -493,6 +497,47 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups lifted boundary errors under the host node card', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const { rootGraph, host } = createBoundaryLinkedSubgraph({
|
||||
interiorType: 'InteriorClass'
|
||||
})
|
||||
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
|
||||
await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
|
||||
})
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError(
|
||||
[
|
||||
validationError(
|
||||
'required_input_missing',
|
||||
'seed_input',
|
||||
{},
|
||||
'Required input is missing'
|
||||
)
|
||||
],
|
||||
'InteriorClass'
|
||||
)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.type).toBe('execution')
|
||||
if (execGroup?.type !== 'execution') return
|
||||
|
||||
const card = execGroup.cards[0]
|
||||
expect(card.nodeId).toBe('12')
|
||||
expect(card.title).toBe(host.title)
|
||||
expect(card.errors[0].displayDetails).toBe(
|
||||
`${host.title} is missing a required input: seed`
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -382,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
if (!executionErrorStore.surfacedNodeErrors) return
|
||||
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
executionErrorStore.surfacedNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
|
||||
@@ -14,20 +14,27 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
defineProps<{
|
||||
const { alpha = true } = defineProps<{
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
alpha?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
function readHsva(hex: string): HSVA {
|
||||
const next = hexToHsva(hex || '#000000')
|
||||
if (!alpha) next.a = 100
|
||||
return next
|
||||
}
|
||||
|
||||
const hsva = ref<HSVA>(readHsva(modelValue.value))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
hsva.value = readHsva(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -67,49 +74,51 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
<slot name="trigger">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</slot>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
@@ -123,6 +132,7 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
:alpha
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
@@ -37,6 +39,7 @@ const { t } = useI18n()
|
||||
/>
|
||||
<ColorPickerSlider v-model="hsva.h" type="hue" />
|
||||
<ColorPickerSlider
|
||||
v-if="alpha"
|
||||
v-model="hsva.a"
|
||||
type="alpha"
|
||||
:hue="hsva.h"
|
||||
@@ -72,7 +75,7 @@ const { t } = useI18n()
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<span class="shrink-0 border-l border-border-subtle pl-1"
|
||||
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
|
||||
@@ -167,10 +167,31 @@ describe('fromBoundingBoxes', () => {
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
desc: 'd',
|
||||
palette: ['#fff']
|
||||
palette: ['#ffffff']
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes palette entries and drops invalid colors', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
|
||||
'#ff0000',
|
||||
'#aabbcc'
|
||||
])
|
||||
})
|
||||
|
||||
it('fills defaults when metadata is missing or partial', () => {
|
||||
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
|
||||
|
||||
@@ -202,6 +202,22 @@ function isBoundingBox(b: unknown): b is BoundingBox {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHexColor(color: unknown): string | null {
|
||||
if (typeof color !== 'string') return null
|
||||
const hex = color.trim().toLowerCase()
|
||||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
|
||||
if (short) {
|
||||
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
|
||||
}
|
||||
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
|
||||
}
|
||||
|
||||
function normalizePalette(palette: unknown): string[] {
|
||||
return Array.isArray(palette)
|
||||
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
|
||||
: []
|
||||
}
|
||||
|
||||
export function fromBoundingBoxes(
|
||||
boxes: readonly BoundingBox[],
|
||||
width: number,
|
||||
@@ -219,9 +235,7 @@ export function fromBoundingBoxes(
|
||||
type: meta.type === 'text' ? 'text' : 'obj',
|
||||
text: typeof meta.text === 'string' ? meta.text : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : '',
|
||||
palette: Array.isArray(meta.palette)
|
||||
? meta.palette.filter((c): c is string => typeof c === 'string')
|
||||
: []
|
||||
palette: normalizePalette(meta.palette)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
const { appState, outputState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown },
|
||||
outputState: {
|
||||
outputs: undefined as unknown,
|
||||
nodeOutputs: null as { value: Record<string, unknown> } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const nodeOutputs = ref<Record<string, unknown>>({})
|
||||
outputState.nodeOutputs = nodeOutputs
|
||||
return {
|
||||
useNodeOutputStore: () => ({
|
||||
nodeOutputs,
|
||||
nodePreviewImages: ref({}),
|
||||
getNodeImageUrls: () => undefined,
|
||||
getNodeOutputs: () => outputState.outputs
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
@@ -27,6 +45,9 @@ const ctx = {
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
moveTo: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
@@ -58,17 +79,32 @@ function makeCanvas(): HTMLCanvasElement {
|
||||
return el
|
||||
}
|
||||
|
||||
function makeNode() {
|
||||
interface MockNode {
|
||||
widgets: { name: string; value: unknown }[]
|
||||
findInputSlot: (name: string) => number
|
||||
getInputNode: () => null
|
||||
isInputConnected?: () => boolean
|
||||
}
|
||||
|
||||
function makeNode(): MockNode {
|
||||
return {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'last_incoming', value: [] }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
}
|
||||
|
||||
const lastIncomingOf = (node: MockNode) =>
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value
|
||||
|
||||
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
|
||||
}
|
||||
|
||||
const pe = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
@@ -96,6 +132,8 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
const modelBoxes = (c: Captured) => c.modelValue.value
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
@@ -128,9 +166,19 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
...over
|
||||
})
|
||||
|
||||
function makeConnectedNode(): MockNode {
|
||||
return {
|
||||
...makeNode(),
|
||||
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
|
||||
isInputConnected: () => true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = makeNode()
|
||||
outputState.outputs = undefined
|
||||
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
void Promise.resolve().then(() => cb(0))
|
||||
return 1
|
||||
@@ -168,8 +216,8 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('discards a zero-size draw', async () => {
|
||||
@@ -177,7 +225,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onDocPointerUp(pe(10, 10))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects an existing region instead of drawing when clicking inside it', async () => {
|
||||
@@ -185,7 +233,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,7 +242,7 @@ describe('useBoundingBoxes region editing', () => {
|
||||
const c = setup([box()])
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.type).toBe('text')
|
||||
expect(modelBoxes(c)[0].metadata.type).toBe('text')
|
||||
})
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
@@ -205,14 +253,18 @@ describe('useBoundingBoxes region editing', () => {
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears all regions', async () => {
|
||||
it('clears all regions and invalidates the applied upstream input', async () => {
|
||||
const node = makeNode()
|
||||
setLastIncomingOf(node, [box()])
|
||||
appState.node = node
|
||||
const c = setup([box(), box({ x: 0 })])
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(lastIncomingOf(node)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,7 +278,7 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
c.inlineEditor.value!.value = 'a label'
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
|
||||
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
@@ -239,6 +291,168 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes incoming bboxes input', () => {
|
||||
it('adopts cached outputs on mount without overwriting existing edits', () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('does not re-apply an already applied output after a remount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
setLastIncomingOf(node, incoming)
|
||||
appState.node = node
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
})
|
||||
|
||||
it('ignores incoming output when the input is not connected', () => {
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('repopulates from the next run after clearing the canvas', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
})
|
||||
|
||||
it('does not apply output updates while the input is disconnected', async () => {
|
||||
let connected = true
|
||||
appState.node = {
|
||||
...makeConnectedNode(),
|
||||
isInputConnected: () => connected
|
||||
}
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
connected = false
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not apply incoming boxes while the user is drawing', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(50, 50))
|
||||
|
||||
outputState.outputs = {
|
||||
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
|
||||
}
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
c.onDocPointerUp(pe(50, 50))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(205)
|
||||
})
|
||||
|
||||
it('applies incoming boxes when outputs stream in after mount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
|
||||
const node = makeConnectedNode()
|
||||
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
|
||||
appState.node = node
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
|
||||
const changed = [box({ x: 64, width: 128 })]
|
||||
outputState.outputs = { input_bboxes: changed }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)[0].width).toBe(128)
|
||||
expect(lastIncomingOf(node)).toEqual(changed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes grid snapping', () => {
|
||||
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].x).toBe(64)
|
||||
expect(modelBoxes(c)[0].width).toBe(256)
|
||||
})
|
||||
|
||||
it('does not snap when grid is disabled', async () => {
|
||||
const c = setup()
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(55, 55))
|
||||
c.onDocPointerUp(pe(55, 55))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(230)
|
||||
})
|
||||
|
||||
it('keeps the anchored edge fixed when resizing a single edge', async () => {
|
||||
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
|
||||
c.onPointerDown(pe(60, 30))
|
||||
c.onCanvasPointerMove(pe(80, 30))
|
||||
c.onDocPointerUp(pe(80, 30))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].x).toBe(51)
|
||||
})
|
||||
|
||||
it('removes a box that a resize collapses to zero size', async () => {
|
||||
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
|
||||
c.onPointerDown(pe(37, 25))
|
||||
c.onCanvasPointerMove(pe(14, 25))
|
||||
c.onDocPointerUp(pe(14, 25))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
it('switches to a pointer cursor over a tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { cloneDeep, isEqual } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
Region
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
@@ -25,6 +27,10 @@ const HANDLE_PX = 8
|
||||
const DIMENSION_STEP = 16
|
||||
const BG_DIM = 0.75
|
||||
const MAX_ELEMENT_COLORS = 5
|
||||
const GRID_PX = 32
|
||||
const MAX_GRID_CELLS = 64
|
||||
const DOT_ALPHA = 0.18
|
||||
const DOT_RADIUS = 1
|
||||
|
||||
interface InlineEditorState {
|
||||
value: string
|
||||
@@ -57,6 +63,7 @@ export function useBoundingBoxes(
|
||||
const hoverTagIndex = ref<number | null>(null)
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
const inlineEditor = ref<InlineEditorState | null>(null)
|
||||
const grid = ref(true)
|
||||
|
||||
const { width: containerWidth } = useElementSize(canvasContainer)
|
||||
|
||||
@@ -96,6 +103,89 @@ export function useBoundingBoxes(
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function gridSpec() {
|
||||
const axisFraction = (size: number) =>
|
||||
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
|
||||
return {
|
||||
fx: axisFraction(widthValue.value),
|
||||
fy: axisFraction(heightValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
function snapFraction(value: number, step: number) {
|
||||
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
|
||||
}
|
||||
|
||||
function snapRegion(region: Region, mode: HitMode): Region {
|
||||
if (!grid.value) return region
|
||||
const { fx, fy } = gridSpec()
|
||||
if (mode === 'move') {
|
||||
return {
|
||||
...region,
|
||||
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
|
||||
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
|
||||
}
|
||||
}
|
||||
const snapLeft =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-l' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-bl'
|
||||
const snapRight =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-r' ||
|
||||
mode === 'resize-tr' ||
|
||||
mode === 'resize-br'
|
||||
const snapTop =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-t' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-tr'
|
||||
const snapBottom =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-b' ||
|
||||
mode === 'resize-bl' ||
|
||||
mode === 'resize-br'
|
||||
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
|
||||
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
|
||||
const x2 = snapRight
|
||||
? snapFraction(region.x + region.w, fx)
|
||||
: region.x + region.w
|
||||
const y2 = snapBottom
|
||||
? snapFraction(region.y + region.h, fy)
|
||||
: region.y + region.h
|
||||
return {
|
||||
...region,
|
||||
x: x1,
|
||||
y: y1,
|
||||
w: Math.max(0, x2 - x1),
|
||||
h: Math.max(0, y2 - y1)
|
||||
}
|
||||
}
|
||||
|
||||
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
const { fx, fy } = gridSpec()
|
||||
if (fx <= 0 || fy <= 0) return
|
||||
const cols = Math.round(1 / fx)
|
||||
const rows = Math.round(1 / fy)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = DOT_ALPHA
|
||||
ctx.fillStyle = getComputedStyle(el).color
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const cx = Math.min(1, i * fx) * W
|
||||
for (let j = 0; j <= rows; j++) {
|
||||
const cy = Math.min(1, j * fy) * H
|
||||
ctx.moveTo(cx + DOT_RADIUS, cy)
|
||||
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
|
||||
}
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function logicalSize() {
|
||||
const el = canvasEl.value
|
||||
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
|
||||
@@ -146,6 +236,8 @@ export function useBoundingBoxes(
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
if (grid.value) drawDots(ctx, W, H)
|
||||
|
||||
const showActive = focused.value || isNodeSelected.value
|
||||
const aIdx = showActive ? activeIndex.value : -1
|
||||
const order = state.value.regions
|
||||
@@ -366,7 +458,7 @@ export function useBoundingBoxes(
|
||||
const dx = mN.x - dragStartNorm.value.x
|
||||
const dy = mN.y - dragStartNorm.value.y
|
||||
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
|
||||
state.value.regions[activeIndex.value] = nb
|
||||
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
@@ -375,7 +467,7 @@ export function useBoundingBoxes(
|
||||
drawing.value = false
|
||||
canvasEl.value?.releasePointerCapture?.(e.pointerId)
|
||||
const b = state.value.regions[activeIndex.value]
|
||||
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
|
||||
if (b && (b.w < 0.005 || b.h < 0.005)) {
|
||||
removeRegion(activeIndex.value)
|
||||
}
|
||||
syncState()
|
||||
@@ -510,6 +602,7 @@ export function useBoundingBoxes(
|
||||
function clearAll() {
|
||||
state.value.regions = []
|
||||
activeIndex.value = -1
|
||||
setLastIncoming([])
|
||||
syncState()
|
||||
}
|
||||
|
||||
@@ -530,6 +623,23 @@ export function useBoundingBoxes(
|
||||
watch(isNodeSelected, () => requestDraw())
|
||||
watch([widthValue, heightValue], () => syncState())
|
||||
|
||||
watch(
|
||||
litegraphNode,
|
||||
(node) => {
|
||||
const props = node?.properties as { bboxGrid?: unknown } | undefined
|
||||
if (props && typeof props.bboxGrid === 'boolean')
|
||||
grid.value = props.bboxGrid
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(grid, (enabled) => {
|
||||
const props = litegraphNode.value?.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (props) props.bboxGrid = enabled
|
||||
requestDraw()
|
||||
})
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
|
||||
const node = litegraphNode.value
|
||||
@@ -580,10 +690,63 @@ export function useBoundingBoxes(
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
|
||||
function lastIncomingWidget() {
|
||||
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
|
||||
}
|
||||
|
||||
function lastIncomingValue(): BoundingBox[] {
|
||||
const value = lastIncomingWidget()?.value
|
||||
return Array.isArray(value) ? (value as BoundingBox[]) : []
|
||||
}
|
||||
|
||||
function setLastIncoming(boxes: BoundingBox[]) {
|
||||
const widget = lastIncomingWidget()
|
||||
if (!widget) return
|
||||
const next = cloneDeep(boxes)
|
||||
widget.value = next
|
||||
widget.callback?.(next)
|
||||
}
|
||||
|
||||
function applyIncomingBoxes(apply = true) {
|
||||
if (drawing.value) return
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const slot = node.findInputSlot('bboxes')
|
||||
if (slot < 0 || !node.isInputConnected(slot)) return
|
||||
const outputs = nodeOutputStore.getNodeOutputs(node) as
|
||||
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
|
||||
| undefined
|
||||
const incoming = outputs?.input_bboxes
|
||||
if (!incoming?.length) return
|
||||
const applied = lastIncomingValue()
|
||||
if (isEqual(incoming, applied)) return
|
||||
if (!apply) {
|
||||
if (!applied.length && state.value.regions.length)
|
||||
setLastIncoming(incoming)
|
||||
return
|
||||
}
|
||||
state.value.regions = fromBoundingBoxes(
|
||||
incoming,
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
activeIndex.value = state.value.regions.length ? 0 : -1
|
||||
setLastIncoming(incoming)
|
||||
syncState()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodeOutputs,
|
||||
() => {
|
||||
updateBgImage()
|
||||
applyIncomingBoxes()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
|
||||
|
||||
updateBgImage()
|
||||
applyIncomingBoxes(false)
|
||||
void nextTick(() => requestDraw())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -608,6 +771,7 @@ export function useBoundingBoxes(
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
syncState,
|
||||
grid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
|
||||
}
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
nodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
||||
): () => void {
|
||||
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
|
||||
|
||||
const stop = watch(
|
||||
[
|
||||
lastNodeErrors,
|
||||
nodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
() => missingMediaStore.missingMediaNodeIds,
|
||||
showErrorsTab
|
||||
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
lastNodeErrors.value,
|
||||
nodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
|
||||
@@ -13,17 +13,12 @@ afterEach(() => {
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
)!
|
||||
return { modelValue, container, picker, ...api }
|
||||
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
|
||||
return { modelValue, container, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
const { modelValue, addColor } = setup(['#000000'])
|
||||
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#c'])
|
||||
})
|
||||
|
||||
it('seeds the picker input with the clicked color before opening it', () => {
|
||||
const { picker, openPicker } = setup(['#112233'])
|
||||
const click = vi.spyOn(picker.value!, 'click')
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#112233')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to white when the slot is empty', () => {
|
||||
const { picker, openPicker } = setup([''])
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
it('updates the color at an index', () => {
|
||||
const { modelValue, updateAt } = setup(['#a', '#b'])
|
||||
updateAt(1, '#123456')
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
it('ignores an update that does not change the color', () => {
|
||||
const { modelValue, updateAt } = setup(['#a'])
|
||||
const before = modelValue.value
|
||||
updateAt(0, '#a')
|
||||
expect(modelValue.value).toBe(before)
|
||||
})
|
||||
|
||||
it('reorders via drag when the pointer crosses another swatch', () => {
|
||||
|
||||
@@ -5,30 +5,16 @@ import { ref } from 'vue'
|
||||
interface UsePaletteSwatchRowOptions {
|
||||
modelValue: Ref<string[]>
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
picker: Readonly<ShallowRef<HTMLInputElement | null>>
|
||||
}
|
||||
|
||||
export function usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container,
|
||||
picker
|
||||
container
|
||||
}: UsePaletteSwatchRowOptions) {
|
||||
const pickerIndex = ref<number | null>(null)
|
||||
|
||||
function openPicker(i: number, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
pickerIndex.value = i
|
||||
const el = picker.value
|
||||
if (!el) return
|
||||
el.value = modelValue.value[i] || '#ffffff'
|
||||
el.click()
|
||||
}
|
||||
|
||||
function onPickerInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (pickerIndex.value === null) return
|
||||
function updateAt(i: number, value: string) {
|
||||
if (modelValue.value[i] === value) return
|
||||
const next = modelValue.value.slice()
|
||||
next[pickerIndex.value] = v
|
||||
next[i] = value
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
|
||||
})
|
||||
|
||||
return {
|
||||
openPicker,
|
||||
onPickerInput,
|
||||
updateAt,
|
||||
remove,
|
||||
addColor,
|
||||
onPointerDown
|
||||
|
||||
@@ -77,6 +77,14 @@ vi.mock('pinia', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: settingGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
@@ -95,6 +103,9 @@ describe('useLoad3d', () => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
|
||||
)
|
||||
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
@@ -356,6 +367,20 @@ describe('useLoad3d', () => {
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
|
||||
Object.defineProperty(mockNode, 'constructor', {
|
||||
value: { comfyClass: 'Save3DAdvanced' },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -383,7 +408,37 @@ describe('useLoad3d', () => {
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
|
||||
})
|
||||
|
||||
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
|
||||
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
|
||||
typeof useCanvasStore
|
||||
>
|
||||
)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
|
||||
})
|
||||
|
||||
it('attaches event listeners before running queued ready callbacks', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
let listenersAttachedWhenCallbackRan = false
|
||||
|
||||
composable.waitForLoad3d(() => {
|
||||
listenersAttachedWhenCallbackRan =
|
||||
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
|
||||
})
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(listenersAttachedWhenCallbackRan).toBe(true)
|
||||
})
|
||||
|
||||
it('passes getZoomScale callback to createLoad3d', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
@@ -118,7 +119,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundColor: getActivePinia()
|
||||
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
|
||||
: '#282828',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
@@ -192,6 +195,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (
|
||||
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
|
||||
node.constructor.comfyClass?.startsWith('Preview') ||
|
||||
!(widthWidget && heightWidget)
|
||||
) {
|
||||
@@ -248,6 +252,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
handleEvents('add')
|
||||
|
||||
const callbacks = pendingCallbacks.get(node)
|
||||
|
||||
if (callbacks && load3d) {
|
||||
@@ -263,8 +269,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) invokeReadyCallback(callback, load3d)
|
||||
})
|
||||
}
|
||||
|
||||
handleEvents('add')
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d:', error)
|
||||
useToastStore().addAlert(
|
||||
|
||||
@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal file
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createBoundaryLinkedSubgraph,
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('liftNodeErrorsToBoundary', () => {
|
||||
it('lifts a boundary-linked slot error to the host', () => {
|
||||
const { host, rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual({
|
||||
'12': {
|
||||
class_type: host.title,
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
expect.objectContaining({
|
||||
type: 'required_input_missing',
|
||||
extra_info: expect.objectContaining({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('lifts a promoted-widget value error to the host input', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode('CheckpointLoaderSimple')
|
||||
interior.id = toNodeId(5)
|
||||
const input = interior.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
|
||||
values: ['present.safetensors']
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
subgraph.add(interior)
|
||||
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('value_not_in_list', 'ckpt_name', {
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'ckpt_name',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'ckpt_name',
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('recurses through nested boundary-linked hosts', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
outerHost.title = 'Outer Host'
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(Object.keys(result)).toEqual(['1'])
|
||||
expect(result['1'].class_type).toBe(outerHost.title)
|
||||
expect(result['1'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '1:2:3',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps errors on ordinary interior data-flow links', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const source = new LGraphNode('SourceNode')
|
||||
source.id = toNodeId(4)
|
||||
source.addOutput('seed', '*')
|
||||
subgraph.add(source)
|
||||
|
||||
const target = new LGraphNode('TargetNode')
|
||||
target.id = toNodeId(5)
|
||||
target.addInput('seed_input', '*')
|
||||
subgraph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps errors without a liftable subject on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing'),
|
||||
validationError('exception_during_validation', 'seed_input'),
|
||||
validationError('dependency_cycle', 'seed_input'),
|
||||
validationError(
|
||||
'custom_validation_failed',
|
||||
'seed_input',
|
||||
{ received_value: 'image.png' },
|
||||
'Invalid image file'
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps unknown typed validation errors on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('future_backend_validation_type', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('splits liftable and non-liftable errors from the same node entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input'),
|
||||
validationError('exception_during_validation', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors).toHaveLength(1)
|
||||
expect(result['12'].errors[0].type).toBe('required_input_missing')
|
||||
expect(result['12:5'].errors).toHaveLength(1)
|
||||
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
|
||||
})
|
||||
|
||||
it('merges a lifted error into an existing host entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12': {
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output'],
|
||||
errors: [validationError('value_smaller_than_min', 'other')]
|
||||
},
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result['12']).toMatchObject({
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output']
|
||||
})
|
||||
expect(result['12'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps own errors before lifted errors for nested host keys', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
]),
|
||||
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
|
||||
})
|
||||
|
||||
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves empty error entries unchanged', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const errors = {
|
||||
'12': nodeError([], 'ExtraRootNode')
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('fails open without mutating the input record', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
const interior = new LGraphNode('InteriorNode')
|
||||
interior.id = toNodeId(5)
|
||||
interior.addInput('unlinked', '*')
|
||||
subgraph.add(interior)
|
||||
|
||||
const errors = {
|
||||
'99:5': nodeError([validationError('required_input_missing', 'x')]),
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'missing'),
|
||||
validationError('value_not_in_list', 'unlinked')
|
||||
])
|
||||
}
|
||||
const original = structuredClone(errors)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual(original)
|
||||
expect(errors).toEqual(original)
|
||||
expect(result).not.toBe(errors)
|
||||
})
|
||||
})
|
||||
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal file
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { groupBy, partition } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export interface LiftedErrorExtraInfo {
|
||||
input_name: string
|
||||
source_execution_id: string
|
||||
source_input_name: string
|
||||
}
|
||||
|
||||
export interface LiftedSurface {
|
||||
hostExecId: NodeExecutionId
|
||||
hostInputName: string
|
||||
}
|
||||
|
||||
interface ErrorPlacement {
|
||||
kind: 'own' | 'lifted'
|
||||
targetExecId: string
|
||||
error: NodeValidationError
|
||||
}
|
||||
|
||||
export function getLiftedErrorSource(
|
||||
error: NodeValidationError
|
||||
): LiftedErrorExtraInfo | null {
|
||||
const extraInfo = error.extra_info
|
||||
if (!extraInfo) return null
|
||||
|
||||
const { input_name, source_execution_id, source_input_name } = extraInfo
|
||||
if (
|
||||
typeof input_name !== 'string' ||
|
||||
typeof source_execution_id !== 'string' ||
|
||||
typeof source_input_name !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { input_name, source_execution_id, source_input_name }
|
||||
}
|
||||
|
||||
function getHostExecutionId(executionId: string): NodeExecutionId | null {
|
||||
const separatorIndex = executionId.lastIndexOf(':')
|
||||
if (separatorIndex <= 0) return null
|
||||
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
|
||||
* Walks one host per level and stops at the last resolvable surface, so an
|
||||
* unresolvable deeper host falls back to the shallower one (fail-open).
|
||||
*/
|
||||
export function resolveLiftChain(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
inputName: string
|
||||
): LiftedSurface[] {
|
||||
const chain: LiftedSurface[] = []
|
||||
let currentExecId = executionId
|
||||
let currentInputName = inputName
|
||||
|
||||
for (;;) {
|
||||
const node = getNodeByExecutionId(rootGraph, currentExecId)
|
||||
const graph = node?.graph
|
||||
if (!node || !graph || !isSubgraph(graph)) break
|
||||
|
||||
const slot = node.inputs?.find((input) => input.name === currentInputName)
|
||||
if (slot?.link == null) break
|
||||
|
||||
const subgraphInput = graph
|
||||
.getLink(slot.link)
|
||||
?.resolve(graph)?.subgraphInput
|
||||
if (!subgraphInput) break
|
||||
|
||||
const hostExecId = getHostExecutionId(currentExecId)
|
||||
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
|
||||
|
||||
chain.push({ hostExecId, hostInputName: subgraphInput.name })
|
||||
currentExecId = hostExecId
|
||||
currentInputName = subgraphInput.name
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
function createEmptyNodeError(nodeError: NodeError): NodeError {
|
||||
return {
|
||||
...nodeError,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
|
||||
function createLiftedHostEntry(
|
||||
rootGraph: LGraph,
|
||||
hostExecId: string
|
||||
): NodeError {
|
||||
return {
|
||||
class_type:
|
||||
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
|
||||
dependent_outputs: [],
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
function toErrorPlacement(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
error: NodeValidationError
|
||||
): ErrorPlacement {
|
||||
const inputName = error.extra_info?.input_name
|
||||
const surface =
|
||||
inputName && !isNodeLevelValidationError(error)
|
||||
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
|
||||
: undefined
|
||||
|
||||
if (!inputName || !surface) {
|
||||
return {
|
||||
kind: 'own',
|
||||
targetExecId: executionId,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
const liftedExtraInfo: LiftedErrorExtraInfo = {
|
||||
input_name: surface.hostInputName,
|
||||
source_execution_id: executionId,
|
||||
source_input_name: inputName
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'lifted',
|
||||
targetExecId: surface.hostExecId,
|
||||
error: {
|
||||
...error,
|
||||
extra_info: {
|
||||
...error.extra_info,
|
||||
...liftedExtraInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function liftNodeErrorsToBoundary(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError>
|
||||
): Record<string, NodeError> {
|
||||
const output: Record<string, NodeError> = {}
|
||||
const placements = Object.entries(nodeErrors).flatMap(
|
||||
([executionId, nodeError]) =>
|
||||
nodeError.errors.map((error) =>
|
||||
toErrorPlacement(rootGraph, executionId, error)
|
||||
)
|
||||
)
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
||||
if (nodeError.errors.length === 0) {
|
||||
output[executionId] = createEmptyNodeError(nodeError)
|
||||
}
|
||||
}
|
||||
|
||||
const placementsByTarget = groupBy(
|
||||
placements,
|
||||
(placement) => placement.targetExecId
|
||||
)
|
||||
|
||||
for (const [targetExecId, targetPlacements] of Object.entries(
|
||||
placementsByTarget
|
||||
)) {
|
||||
const baseEntry = nodeErrors[targetExecId]
|
||||
? createEmptyNodeError(nodeErrors[targetExecId])
|
||||
: createLiftedHostEntry(rootGraph, targetExecId)
|
||||
|
||||
const [ownErrors, liftedErrors] = partition(
|
||||
targetPlacements,
|
||||
(placement) => placement.kind === 'own'
|
||||
)
|
||||
|
||||
output[targetExecId] = {
|
||||
...baseEntry,
|
||||
errors: [...ownErrors, ...liftedErrors].map(
|
||||
(placement) => placement.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -32,7 +32,8 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
|
||||
const widgets: MockWidget[] = [
|
||||
{ name: 'width', hidden: false, options: {} },
|
||||
{ name: 'height', hidden: false, options: {} },
|
||||
{ name: 'other', hidden: false, options: {} }
|
||||
{ name: 'other', hidden: false, options: {} },
|
||||
{ name: 'last_incoming', hidden: false, options: {} }
|
||||
]
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
@@ -73,6 +74,15 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
|
||||
expect(node.widgets[0].options.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('always hides the internal last_incoming widget', () => {
|
||||
for (const connected of [true, false]) {
|
||||
const node = makeNode(connected)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.widgets[3].hidden).toBe(true)
|
||||
expect(node.widgets[3].options.hidden).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes visibility through the widget value store when present', () => {
|
||||
state.widgetState = { options: {} }
|
||||
const node = makeNode(true)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const DIMENSION_WIDGETS = new Set(['width', 'height'])
|
||||
const INTERNAL_WIDGETS = new Set(['last_incoming'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateBoundingBoxes',
|
||||
@@ -15,20 +16,30 @@ useExtensionService().registerExtension({
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const setWidgetHidden = (
|
||||
widget: NonNullable<typeof node.widgets>[number],
|
||||
hidden: boolean
|
||||
) => {
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
|
||||
const syncDimensionVisibility = () => {
|
||||
const slot = node.findInputSlot('background')
|
||||
const hidden = slot >= 0 && node.isInputConnected(slot)
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (!DIMENSION_WIDGETS.has(widget.name)) continue
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
|
||||
}
|
||||
|
||||
syncDimensionVisibility()
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
|
||||
@@ -143,14 +143,23 @@ async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
save3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
load3DExt: extByName('Comfy.Load3D'),
|
||||
preview3DExt: extByName('Comfy.Preview3D'),
|
||||
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
|
||||
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,14 +273,15 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -711,6 +721,39 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('restores the saved model from the output folder when opened from history', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['3d\\ComfyUI_00001.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001.glb',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['mesh.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
@@ -1032,6 +1075,50 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder, not temp', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
comfyClass: 'Save3DAdvanced',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
|
||||
})
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted loads the saved file from the output folder', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D scene widget serializeValue caching', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import {
|
||||
LOAD3D_NONE_MODEL,
|
||||
@@ -48,6 +50,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const inputSpecLoad3D: CustomInputSpec = {
|
||||
@@ -287,8 +290,11 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
const VIEWPORT_STATE_NODES = new Set([
|
||||
'Preview3DAdvanced',
|
||||
'Save3DAdvanced',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
'PreviewPointCloud',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
])
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
@@ -679,155 +685,215 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
function applyPreview3DAdvancedResult(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
result: NonNullable<Preview3DAdvancedOutput['result']>,
|
||||
loadFolder: LoadFolder,
|
||||
comfyClass: string
|
||||
): void {
|
||||
const filePath = result[0]
|
||||
if (!filePath) return
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!cameraState && !modelTransform) return
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
function createPreview3DAdvancedExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
): ComfyExtension {
|
||||
return {
|
||||
name: extensionName,
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
onNodeOutputsUpdated(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
) {
|
||||
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const result = (output as Preview3DAdvancedOutput).result
|
||||
if (!result?.[0]) continue
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node || node.constructor.comfyClass !== comfyClass) continue
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to restore camera state for Preview3DAdvanced:',
|
||||
error
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
load3d,
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== comfyClass) return []
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== comfyClass) return
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
await nextTick()
|
||||
|
||||
const currentLoad3d = resolveLoad3d()
|
||||
const config = new Load3DConfiguration(currentLoad3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
const onExecuted = node.onExecuted
|
||||
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
|
||||
|
||||
onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = currentLoad3d.currentLoadGeneration
|
||||
void currentLoad3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
|
||||
return
|
||||
if (cameraState) currentLoad3d.setCameraState(cameraState)
|
||||
if (modelTransform)
|
||||
currentLoad3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to restore camera state for ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
if (!result?.[0]) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Preview3DAdvanced',
|
||||
'Comfy.Preview3DAdvanced',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Save3DAdvanced',
|
||||
'Comfy.Save3DAdvanced',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ export type MaterialMode =
|
||||
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
export type BackgroundRenderModeType = 'tiled' | 'panorama'
|
||||
export type LoadFolder = 'temp' | 'output'
|
||||
|
||||
interface CameraQuaternion {
|
||||
x: number
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
* Adding a new node type that uses the viewer = one line change here.
|
||||
*/
|
||||
|
||||
const LOAD3D_PREVIEW_NODES = new Set([
|
||||
const LOAD3D_RESULT_VIEWER_NODES = new Set([
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
'PreviewPointCloud',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
])
|
||||
|
||||
const LOAD3D_ALL_NODES = new Set([
|
||||
...LOAD3D_PREVIEW_NODES,
|
||||
...LOAD3D_RESULT_VIEWER_NODES,
|
||||
'Load3D',
|
||||
'Load3DAdvanced',
|
||||
'SaveGLB'
|
||||
])
|
||||
|
||||
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
|
||||
LOAD3D_PREVIEW_NODES.has(nodeType)
|
||||
export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
|
||||
LOAD3D_RESULT_VIEWER_NODES.has(nodeType)
|
||||
|
||||
export const isLoad3dNode = (nodeType: string): boolean =>
|
||||
LOAD3D_ALL_NODES.has(nodeType)
|
||||
|
||||
@@ -90,7 +90,10 @@ describe('load3dLazy', () => {
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'SaveGLB'
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
])(
|
||||
'recognizes %s as a 3D node type and triggers the lazy-load path',
|
||||
async (nodeType) => {
|
||||
|
||||
@@ -76,14 +76,24 @@ type ExtCreated = ComfyExtension & {
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
splatExt: ExtCreated
|
||||
pointCloudExt: ExtCreated
|
||||
saveSplatExt: ExtCreated
|
||||
savePointCloudExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3dPreviewExtensions')
|
||||
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
return {
|
||||
splatExt: splatCall[0] as ExtCreated,
|
||||
pointCloudExt: pointCloudCall[0] as ExtCreated
|
||||
splatExt: extByName('Comfy.PreviewGaussianSplat'),
|
||||
pointCloudExt: extByName('Comfy.PreviewPointCloud'),
|
||||
saveSplatExt: extByName('Comfy.SaveGaussianSplat'),
|
||||
savePointCloudExt: extByName('Comfy.SavePointCloud')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +102,7 @@ interface FakeLoad3d {
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
forceRender: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
setTargetSize: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
@@ -106,6 +117,7 @@ function makeLoad3dMock(): FakeLoad3d {
|
||||
isSplatModel: vi.fn(() => false),
|
||||
forceRender: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
applyModelTransform: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
|
||||
@@ -151,12 +163,59 @@ function setupBaseMocks() {
|
||||
describe('load3dPreviewExtensions module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers both preview extensions on import', async () => {
|
||||
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
|
||||
it('registers preview and save extensions on import', async () => {
|
||||
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
|
||||
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
|
||||
expect(saveSplatExt.name).toBe('Comfy.SaveGaussianSplat')
|
||||
expect(savePointCloudExt.name).toBe('Comfy.SavePointCloud')
|
||||
})
|
||||
|
||||
it('save extensions load the saved file from the output folder, not temp', async () => {
|
||||
const { saveSplatExt, savePointCloudExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const splatNode = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
|
||||
await saveSplatExt.nodeCreated(splatNode)
|
||||
splatNode.onExecuted!({ result: ['3d/ComfyUI_00001_.ply'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
|
||||
const pcNode = makePreviewNode({ comfyClass: 'SavePointCloud' })
|
||||
await savePointCloudExt.nodeCreated(pcNode)
|
||||
pcNode.onExecuted!({ result: ['3d/ComfyUI_00002_.ply'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder on nodeCreated, not temp', async () => {
|
||||
const { saveSplatExt } = await loadExtensionsFresh()
|
||||
const node = makePreviewNode({
|
||||
comfyClass: 'SaveGaussianSplat',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.ply' }
|
||||
})
|
||||
|
||||
await saveSplatExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,6 +273,44 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
expect(cameraConfig?.state).toEqual(cameraState)
|
||||
})
|
||||
|
||||
it('applies onExecuted results to the remounted instance, not the disposed closure', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const original = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(original)
|
||||
)
|
||||
const node = makePreviewNode()
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
|
||||
const remounted = makeLoad3dMock()
|
||||
nodeToLoad3dMapMock.set(node, remounted)
|
||||
|
||||
node.onExecuted!({
|
||||
result: ['scene.ply', { position: { x: 1, y: 2, z: 3 } }]
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(remounted.forceRender).toHaveBeenCalled()
|
||||
expect(original.forceRender).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-applies the model transform from result[2] on execute', async () => {
|
||||
const { saveSplatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
|
||||
const transform = { position: { x: 1, y: 2, z: 3 } }
|
||||
|
||||
await saveSplatExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['scene.ply', undefined, [transform]] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
})
|
||||
|
||||
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
@@ -29,7 +30,9 @@ function applyResultToLoad3d(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
filePath: string,
|
||||
cameraState: CameraState | undefined
|
||||
cameraState: CameraState | undefined,
|
||||
modelTransform: Model3DInfo[number] | undefined,
|
||||
loadFolder: LoadFolder
|
||||
): void {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
@@ -46,7 +49,7 @@ function applyResultToLoad3d(
|
||||
}
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -54,13 +57,15 @@ function applyResultToLoad3d(
|
||||
void load3d.whenLoadIdle().then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
load3d.forceRender()
|
||||
})
|
||||
}
|
||||
|
||||
function createPreview3DExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
): ComfyExtension {
|
||||
const applyPreviewOutput = (
|
||||
node: LGraphNode,
|
||||
@@ -68,10 +73,18 @@ function createPreview3DExtension(
|
||||
): void => {
|
||||
const filePath = result[0]
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!filePath) return
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyResultToLoad3d(node, load3d, filePath, cameraState)
|
||||
applyResultToLoad3d(
|
||||
node,
|
||||
load3d,
|
||||
filePath,
|
||||
cameraState,
|
||||
modelTransform,
|
||||
loadFolder
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +132,7 @@ function createPreview3DExtension(
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -136,6 +149,8 @@ function createPreview3DExtension(
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
@@ -148,10 +163,10 @@ function createPreview3DExtension(
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(value, heightWidget.value as number)
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(widthWidget.value as number, value)
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +214,14 @@ function createPreview3DExtension(
|
||||
return
|
||||
}
|
||||
|
||||
applyResultToLoad3d(node, load3d, filePath, result?.[1])
|
||||
applyResultToLoad3d(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
filePath,
|
||||
result?.[1],
|
||||
result?.[2]?.[0],
|
||||
loadFolder
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -207,8 +229,26 @@ function createPreview3DExtension(
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
|
||||
createPreview3DExtension(
|
||||
'PreviewGaussianSplat',
|
||||
'Comfy.PreviewGaussianSplat',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
|
||||
createPreview3DExtension(
|
||||
'PreviewPointCloud',
|
||||
'Comfy.PreviewPointCloud',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension(
|
||||
'SaveGaussianSplat',
|
||||
'Comfy.SaveGaussianSplat',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
|
||||
)
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
updateTextPreviewWidgets
|
||||
} from '@/extensions/core/textPreviewWidgets'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.PreviewAny',
|
||||
@@ -30,5 +32,11 @@ useExtensionService().registerExtension({
|
||||
onExecuted?.apply(this, [message])
|
||||
updateTextPreviewWidgets(this, message)
|
||||
}
|
||||
},
|
||||
onNodeOutputsUpdated(nodeOutputs) {
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
|
||||
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -81,6 +81,9 @@ describe('Comfy.SaveImageExtraOutput', () => {
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
@@ -16,6 +16,9 @@ const saveNodeTypes = new Set([
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'Save3DAdvanced',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
|
||||
80
src/i18n.safeTranslation.test.ts
Normal file
80
src/i18n.safeTranslation.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n, st, stRaw } from './i18n'
|
||||
|
||||
const TEST_NAMESPACE = 'safeTranslationTest'
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
const messages = i18n.global.getLocaleMessage('en')
|
||||
delete (messages as Record<string, unknown>)[TEST_NAMESPACE]
|
||||
i18n.global.setLocaleMessage('en', messages)
|
||||
})
|
||||
|
||||
describe('st', () => {
|
||||
it('returns the fallback when the key is not found', () => {
|
||||
expect(st('safeTranslationTest.missing', 'Fallback value')).toBe(
|
||||
'Fallback value'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses compiled translations for valid locale messages', () => {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
valid: 'Translated value'
|
||||
}
|
||||
})
|
||||
|
||||
expect(st('safeTranslationTest.valid', 'Fallback value')).toBe(
|
||||
'Translated value'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns raw locale messages when vue-i18n compilation fails', () => {
|
||||
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
|
||||
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
invalidLinkedFormat: message
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
st('safeTranslationTest.invalidLinkedFormat', 'Fallback value')
|
||||
).toBe(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stRaw', () => {
|
||||
it('returns raw locale messages for valid keys', () => {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
rawValue: 'Raw value'
|
||||
}
|
||||
})
|
||||
|
||||
expect(stRaw('safeTranslationTest.rawValue', 'Fallback value')).toBe(
|
||||
'Raw value'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns raw messages containing vue-i18n syntax', () => {
|
||||
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
|
||||
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
rawSyntax: message
|
||||
}
|
||||
})
|
||||
|
||||
expect(stRaw('safeTranslationTest.rawSyntax', 'Fallback value')).toBe(
|
||||
message
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the fallback when the key is not found', () => {
|
||||
expect(stRaw('safeTranslationTest.rawMissing', 'Fallback value')).toBe(
|
||||
'Fallback value'
|
||||
)
|
||||
})
|
||||
})
|
||||
20
src/i18n.ts
20
src/i18n.ts
@@ -159,15 +159,28 @@ export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
|
||||
function rawTranslationOrFallback(key: string, fallbackMessage: string) {
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
* Invalid message syntax falls back to the raw locale message instead of crashing.
|
||||
*
|
||||
* @param key - The key to translate.
|
||||
* @param fallbackMessage - The fallback message to use if the key is not found.
|
||||
*/
|
||||
export function st(key: string, fallbackMessage: string) {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return te(key) ? t(key) : fallbackMessage
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
try {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return t(key)
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +193,5 @@ export function st(key: string, fallbackMessage: string) {
|
||||
export function stRaw(key: string, fallbackMessage: string) {
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
@@ -86,6 +87,23 @@ interface TestSubgraphNodeOptions {
|
||||
size?: [number, number]
|
||||
}
|
||||
|
||||
interface BoundaryLinkedSubgraphOptions {
|
||||
rootGraph?: LGraph
|
||||
hostId?: SerializedNodeId
|
||||
interiorId?: SerializedNodeId
|
||||
boundaryName?: string
|
||||
inputName?: string
|
||||
hostTitle?: string
|
||||
interiorType?: string
|
||||
}
|
||||
|
||||
export interface BoundaryLinkedSubgraphFixture {
|
||||
rootGraph: LGraph
|
||||
subgraph: Subgraph
|
||||
host: SubgraphNode
|
||||
interior: LGraphNode
|
||||
}
|
||||
|
||||
interface NestedSubgraphOptions {
|
||||
depth?: number
|
||||
nodesPerLevel?: number
|
||||
@@ -269,6 +287,32 @@ export function createTestSubgraphNode(
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
}
|
||||
|
||||
export function createBoundaryLinkedSubgraph({
|
||||
rootGraph = createTestRootGraph(),
|
||||
hostId = 12,
|
||||
interiorId = 5,
|
||||
boundaryName = 'seed',
|
||||
inputName = 'seed_input',
|
||||
hostTitle = 'Host Subgraph',
|
||||
interiorType = 'InteriorNode'
|
||||
}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture {
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: boundaryName, type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host.title = hostTitle
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode(interiorType)
|
||||
interior.id = toNodeId(interiorId)
|
||||
const input = interior.addInput(inputName, '*')
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(input, interior)
|
||||
|
||||
return { rootGraph, subgraph, host, interior }
|
||||
}
|
||||
|
||||
export function setupComplexPromotionFixture(): {
|
||||
graph: LGraph
|
||||
subgraph: Subgraph
|
||||
|
||||
@@ -2170,7 +2170,8 @@
|
||||
"descLabel": "description",
|
||||
"textPlaceholder": "text to render (verbatim)",
|
||||
"descPlaceholder": "description of this region",
|
||||
"colors": "color_palette"
|
||||
"colors": "color_palette",
|
||||
"grid": "Grid"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "Add a color",
|
||||
|
||||
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
|
||||
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
|
||||
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
|
||||
* sync). `collect-i18n-node-defs.ts` escapes such values with
|
||||
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
|
||||
* output actually compiles and renders the original literal text.
|
||||
*/
|
||||
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
|
||||
const compile = (message: string) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
it.for([
|
||||
'clips (tagged @Audio1-3 in the prompt)',
|
||||
'support@comfy.org',
|
||||
'resolution {width}x{height}',
|
||||
'foreground | background',
|
||||
'50%{done}',
|
||||
'all of @ { } | % together',
|
||||
'no special chars here'
|
||||
])('renders %s as the original literal text', (raw) => {
|
||||
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,10 @@
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
export type NodeValidationError = NodeError['errors'][number]
|
||||
export type { NodeValidationError }
|
||||
|
||||
export interface ResolvedErrorMessage {
|
||||
catalogId?: string
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
translateOptionalCatalogMessage
|
||||
} from './catalogI18n'
|
||||
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
|
||||
import {
|
||||
INPUT_LEVEL_VALIDATION_ERROR_TYPES,
|
||||
NODE_LEVEL_VALIDATION_ERROR_TYPES,
|
||||
getInputConfigBounds,
|
||||
isImageNotLoadedValidationError
|
||||
} from '@/utils/executionErrorUtil'
|
||||
|
||||
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
|
||||
|
||||
@@ -62,51 +68,31 @@ const VALUE_SPECIFIC_COPY_RULES: Record<
|
||||
}
|
||||
}
|
||||
|
||||
const NODE_LEVEL_VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> =
|
||||
Object.fromEntries(
|
||||
Array.from(NODE_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
|
||||
type,
|
||||
{ catalogId: type, itemLabel: 'node' } satisfies ValidationCatalogRule
|
||||
])
|
||||
)
|
||||
|
||||
const INPUT_LEVEL_VALIDATION_ERROR_RULES: Record<
|
||||
string,
|
||||
ValidationCatalogRule
|
||||
> = Object.fromEntries(
|
||||
Array.from(INPUT_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
|
||||
type,
|
||||
{ catalogId: type, itemLabel: 'nodeInput' } satisfies ValidationCatalogRule
|
||||
])
|
||||
)
|
||||
|
||||
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
|
||||
...INPUT_LEVEL_VALIDATION_ERROR_RULES,
|
||||
[REQUIRED_INPUT_MISSING_TYPE]: {
|
||||
catalogId: MISSING_CONNECTION_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'
|
||||
}
|
||||
...NODE_LEVEL_VALIDATION_ERROR_RULES
|
||||
}
|
||||
|
||||
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
|
||||
@@ -131,26 +117,6 @@ function getInputName(error: NodeValidationError): string {
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
@@ -179,13 +145,7 @@ 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])
|
||||
return formatCatalogValue(getInputConfigBounds(error)[key])
|
||||
}
|
||||
|
||||
function getInputConfigType(error: NodeValidationError): string | undefined {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<img
|
||||
v-if="option.logo"
|
||||
:src="option.logo"
|
||||
:alt="option.label"
|
||||
alt=""
|
||||
class="size-4"
|
||||
/>
|
||||
{{ option.label }}
|
||||
|
||||
@@ -268,6 +268,27 @@ describe('useSecretForm', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('passes a server-listed provider absent from the local registry through with its raw id as label and no logo', () => {
|
||||
const visible = ref(true)
|
||||
const { providerOptions } = useSecretForm({
|
||||
mode: 'create',
|
||||
existingProviders: () => [],
|
||||
availableProviders: () => ['brand-new-provider'],
|
||||
visible,
|
||||
onSaved: vi.fn()
|
||||
})
|
||||
|
||||
expect(providerOptions.value).toEqual([
|
||||
{
|
||||
value: 'brand-new-provider',
|
||||
label: 'brand-new-provider',
|
||||
logo: undefined,
|
||||
disabled: false
|
||||
}
|
||||
])
|
||||
expect(providerOptions.value[0]?.logo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits BYOK providers the server does not list', () => {
|
||||
const visible = ref(true)
|
||||
const { providerOptions } = useSecretForm({
|
||||
|
||||
@@ -101,8 +101,11 @@ export function useSecretForm(options: UseSecretFormOptions) {
|
||||
|
||||
// Once the server allowlist resolves, drop a selection the resolved list no
|
||||
// longer offers so the user cannot submit an unlisted provider.
|
||||
watch(providerOptions, (options) => {
|
||||
if (form.provider && !options.some((o) => o.value === form.provider)) {
|
||||
watch(providerOptions, (resolvedOptions) => {
|
||||
if (
|
||||
form.provider &&
|
||||
!resolvedOptions.some((o) => o.value === form.provider)
|
||||
) {
|
||||
form.provider = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,6 +39,11 @@ export function useSettingsDialog() {
|
||||
// breaks those nested dialogs' autofocus and click handling. Non-modal
|
||||
// keeps the visual overlay without those traps.
|
||||
modal: false,
|
||||
// A nested dialog closing (e.g. confirming a Secrets delete) can move
|
||||
// focus onto an app element once the row it focused is removed. As a
|
||||
// non-modal dialog Settings would treat that as an outside focus and
|
||||
// dismiss itself, so opt out — escape and outside clicks still close it.
|
||||
dismissOnFocusOutside: false,
|
||||
size: 'full',
|
||||
contentClass: SETTINGS_CONTENT_CLASS,
|
||||
overlayClass: isWorkspaceMode ? 'p-8' : undefined
|
||||
|
||||
@@ -35,13 +35,13 @@ const inputNodeIds = computed(() => {
|
||||
})
|
||||
|
||||
const accessibleNodeErrors = computed(() =>
|
||||
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
|
||||
Object.keys(executionErrorStore.surfacedNodeErrors ?? {}).filter((k) =>
|
||||
inputNodeIds.value.has(k)
|
||||
)
|
||||
)
|
||||
const accessibleErrors = computed(() =>
|
||||
accessibleNodeErrors.value.flatMap((k) => {
|
||||
const nodeError = executionErrorStore.lastNodeErrors?.[k]
|
||||
const nodeError = executionErrorStore.surfacedNodeErrors?.[k]
|
||||
if (!nodeError) return []
|
||||
|
||||
return nodeError.errors.flatMap((error) => {
|
||||
|
||||
@@ -252,6 +252,41 @@ describe('hasWidgetError', () => {
|
||||
).toBe(true)
|
||||
expect(spy).toHaveBeenCalledWith('1', 'display_slot')
|
||||
})
|
||||
|
||||
it('matches raw interior errors by the source widget name for promoted widgets', () => {
|
||||
const sourceExecutionId = createNodeExecutionId([
|
||||
toNodeId(65),
|
||||
toNodeId(18)
|
||||
])
|
||||
const widget = createMockWidget({
|
||||
name: 'display_slot',
|
||||
sourceExecutionId,
|
||||
sourceWidgetName: 'ckpt_name'
|
||||
})
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
[sourceExecutionId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_not_in_list',
|
||||
message: 'Invalid model',
|
||||
details: '',
|
||||
extra_info: { input_name: 'ckpt_name' }
|
||||
}
|
||||
],
|
||||
class_type: 'CheckpointLoaderSimple',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
createNodeExecutionId([toNodeId(1)]),
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const noopUi = {
|
||||
@@ -667,6 +702,36 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('clears raw interior errors through widget.sourceExecutionId, which boundary lift relies on', () => {
|
||||
const sourceExecutionId = createNodeExecutionId([65, 18])
|
||||
const widget = createMockWidget({
|
||||
name: 'display_slot',
|
||||
nodeId: NODE_ID,
|
||||
sourceExecutionId,
|
||||
sourceWidgetName: 'ckpt_name'
|
||||
})
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
[sourceExecutionId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_not_in_list',
|
||||
message: 'Invalid model',
|
||||
details: '',
|
||||
extra_info: { input_name: 'ckpt_name' }
|
||||
}
|
||||
],
|
||||
class_type: 'CheckpointLoaderSimple',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler('real_model.safetensors')
|
||||
|
||||
expect(executionErrorStore.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears execution errors on update', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
|
||||
@@ -130,8 +130,12 @@ export function hasWidgetError(
|
||||
const errors = widget.sourceExecutionId
|
||||
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
|
||||
: nodeErrors?.errors
|
||||
// Raw interior errors name the source widget, not the boundary name
|
||||
const errorInputName = widget.sourceExecutionId
|
||||
? (widget.sourceWidgetName ?? widget.name)
|
||||
: widget.name
|
||||
return (
|
||||
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
|
||||
!!errors?.some((e) => e.extra_info?.input_name === errorInputName) ||
|
||||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
import { useBoundingBoxesWidget } from './useBoundingBoxesWidget'
|
||||
|
||||
const widgetOptions = { serialize: true, canvasOnly: false }
|
||||
const widgetOptions = { serialize: true, canvasOnly: false, hideInPanel: true }
|
||||
|
||||
function mockNode() {
|
||||
return { addWidget: vi.fn(() => ({})) } as unknown as LGraphNode & {
|
||||
|
||||
@@ -17,7 +17,8 @@ export const useBoundingBoxesWidget = (): ComfyWidgetConstructorV2 => {
|
||||
})) ?? []
|
||||
return node.addWidget('boundingboxes', spec.name, defaultValue, null, {
|
||||
serialize: true,
|
||||
canvasOnly: false
|
||||
canvasOnly: false,
|
||||
hideInPanel: true
|
||||
}) as IBaseWidget
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ interface CustomDialogComponentProps {
|
||||
pt?: DialogPassThroughOptions
|
||||
closeOnEscape?: boolean
|
||||
dismissableMask?: boolean
|
||||
/**
|
||||
* When `false`, the Reka dialog does not dismiss when focus leaves its
|
||||
* content. Set on container dialogs (e.g. Settings) that host nested dialogs,
|
||||
* where a nested dialog closing can move focus onto an ordinary app element
|
||||
* — a programmatic shift that must not be read as a dismiss. Escape and
|
||||
* outside-pointer dismissal are unaffected. Defaults to `true`.
|
||||
*/
|
||||
dismissOnFocusOutside?: boolean
|
||||
unstyled?: boolean
|
||||
headless?: boolean
|
||||
renderer?: DialogRenderer
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createBoundaryLinkedSubgraph,
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -39,11 +51,20 @@ import { useExecutionErrorStore } from './executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
function mockGraphReady(rootGraph: typeof app.rootGraph) {
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
||||
}
|
||||
|
||||
describe('executionErrorStore — node error operations', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('clearSimpleNodeErrors', () => {
|
||||
it('does nothing if lastNodeErrors is null', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
@@ -296,6 +317,97 @@ describe('executionErrorStore — node error operations', () => {
|
||||
// Error should remain
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears a lifted host slot error from the raw interior record', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(store.surfacedNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear lifted host slot errors when the raw error is not simple', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError(
|
||||
'custom_validation_failed',
|
||||
'seed_input',
|
||||
{},
|
||||
'Custom validation failed'
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
|
||||
|
||||
expect(store.lastNodeErrors).toHaveProperty('12:5')
|
||||
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears a nested lifted error fixed at an intermediate host level', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('1')
|
||||
|
||||
store.clearSimpleNodeErrors(
|
||||
createNodeExecutionId([toNodeId(1), toNodeId(2)]),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(
|
||||
store.lastNodeErrors,
|
||||
'a fix at the intermediate host clears the raw interior error'
|
||||
).toBeNull()
|
||||
expect(store.surfacedNodeErrors).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors', () => {
|
||||
@@ -388,6 +500,137 @@ describe('executionErrorStore — node error operations', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('validates the base target against live widget bounds, not recorded ones', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'123': nodeError([
|
||||
validationError('value_bigger_than_max', 'testWidget', {
|
||||
input_config: ['INT', { max: 100 }]
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
150,
|
||||
{ max: 200 }
|
||||
)
|
||||
|
||||
expect(
|
||||
store.lastNodeErrors,
|
||||
'a value within the refreshed widget bounds clears despite stale recorded bounds'
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear lifted range errors until the host value is in range', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('value_bigger_than_max', 'seed_input', {}, 'Too high')
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(12)]),
|
||||
'seed',
|
||||
'seed',
|
||||
200,
|
||||
{ max: 100 }
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toHaveProperty('12:5')
|
||||
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(12)]),
|
||||
'seed',
|
||||
'seed',
|
||||
50,
|
||||
{ max: 100 }
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears fan-out lifted targets per their own recorded bounds', () => {
|
||||
const { rootGraph, subgraph } = createBoundaryLinkedSubgraph()
|
||||
const second = new LGraphNode('SecondInterior')
|
||||
second.id = toNodeId(7)
|
||||
const secondInput = second.addInput('other_input', '*')
|
||||
subgraph.add(second)
|
||||
subgraph.inputNode.slots[0].connect(secondInput, second)
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('value_bigger_than_max', 'seed_input', {
|
||||
input_config: ['INT', { max: 100 }]
|
||||
})
|
||||
]),
|
||||
'12:7': nodeError([
|
||||
validationError('value_bigger_than_max', 'other_input', {
|
||||
input_config: ['INT', { max: 50 }]
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors?.['12'].errors).toHaveLength(2)
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(12)]),
|
||||
'seed',
|
||||
'seed',
|
||||
75,
|
||||
{ max: 100 }
|
||||
)
|
||||
|
||||
expect(
|
||||
store.lastNodeErrors?.['12:5'],
|
||||
'the target whose max=100 is satisfied by 75 clears'
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.lastNodeErrors?.['12:7'].errors,
|
||||
'the target whose max=50 is still violated by 75 stays'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfacedNodeErrors', () => {
|
||||
it('derives boundary-lifted errors while preserving the raw record', () => {
|
||||
const { rootGraph, host } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const hostLocatorId = createNodeLocatorId(null, toNodeId(12))
|
||||
|
||||
expect(store.lastNodeErrors).toHaveProperty('12:5')
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
expect(
|
||||
store.surfacedNodeErrors?.['12'].errors[0].extra_info
|
||||
).toMatchObject({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
expect(store.getNodeErrors(hostLocatorId)?.class_type).toBe(host.title)
|
||||
expect(store.allErrorExecutionIds).toEqual(['12'])
|
||||
expect(store.activeGraphErrorNodeIds).toEqual(new Set(['12']))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
|
||||
import {
|
||||
getLiftedErrorSource,
|
||||
liftNodeErrorsToBoundary,
|
||||
resolveLiftChain
|
||||
} from '@/core/graph/subgraph/liftNodeErrorsToBoundary'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
@@ -16,7 +21,10 @@ import type {
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import {
|
||||
getAncestorExecutionIds,
|
||||
tryNormalizeNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
executionIdToNodeLocatorId,
|
||||
@@ -25,10 +33,18 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
SIMPLE_ERROR_TYPES,
|
||||
getInputConfigBounds,
|
||||
isValueStillOutOfRange
|
||||
} from '@/utils/executionErrorUtil'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
|
||||
interface SlotNodeErrorClearTarget {
|
||||
executionId: NodeExecutionId
|
||||
slotName: string
|
||||
/** Interior targets validate against the bounds recorded on their errors. */
|
||||
useRecordedBounds?: boolean
|
||||
}
|
||||
|
||||
/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
|
||||
export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -79,31 +95,27 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
lastPromptError.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node's errors if they consist entirely of simple, auto-resolvable
|
||||
* types. When `slotName` is provided, only errors for that slot are checked.
|
||||
*/
|
||||
function clearSimpleNodeErrors(
|
||||
function clearSimpleNodeErrorsFromRecord(
|
||||
nodeErrors: Record<string, NodeError>,
|
||||
executionId: NodeExecutionId,
|
||||
slotName?: string
|
||||
): void {
|
||||
if (!lastNodeErrors.value) return
|
||||
const nodeError = lastNodeErrors.value[executionId]
|
||||
if (!nodeError) return
|
||||
): Record<string, NodeError> | null {
|
||||
const nodeError = nodeErrors[executionId]
|
||||
if (!nodeError) return null
|
||||
|
||||
const isSlotScoped = slotName !== undefined
|
||||
|
||||
const relevantErrors = isSlotScoped
|
||||
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
|
||||
: nodeError.errors
|
||||
|
||||
if (relevantErrors.length === 0) return
|
||||
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) return
|
||||
if (relevantErrors.length === 0) return null
|
||||
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updated = { ...lastNodeErrors.value }
|
||||
const updated = { ...nodeErrors }
|
||||
|
||||
if (isSlotScoped) {
|
||||
// Remove only the target slot's errors if they were all simple
|
||||
const remainingErrors = nodeError.errors.filter(
|
||||
(e) => e.extra_info?.input_name !== slotName
|
||||
)
|
||||
@@ -116,16 +128,150 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no slot specified and all errors were simple, clear the whole node
|
||||
delete updated[executionId]
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw interior sources of lifted errors whose boundary chain passes through
|
||||
* `(executionId, slotName)`, so a fix at any host level — final surface or
|
||||
* intermediate — clears the error at its raw key.
|
||||
*/
|
||||
function getLiftedErrorSourceTargets(
|
||||
executionId: NodeExecutionId,
|
||||
slotName: string
|
||||
): SlotNodeErrorClearTarget[] {
|
||||
const surfaced = surfacedNodeErrors.value
|
||||
if (!surfaced || !app.isGraphReady) return []
|
||||
|
||||
return Object.values(surfaced).flatMap((surface) =>
|
||||
surface.errors.flatMap((error): SlotNodeErrorClearTarget[] => {
|
||||
const source = getLiftedErrorSource(error)
|
||||
if (!source) return []
|
||||
|
||||
const sourceExecutionId = tryNormalizeNodeExecutionId(
|
||||
source.source_execution_id
|
||||
)
|
||||
if (!sourceExecutionId) return []
|
||||
|
||||
const clearsThisError = resolveLiftChain(
|
||||
app.rootGraph,
|
||||
sourceExecutionId,
|
||||
source.source_input_name
|
||||
).some(
|
||||
(level) =>
|
||||
level.hostExecId === executionId && level.hostInputName === slotName
|
||||
)
|
||||
return clearsThisError
|
||||
? [
|
||||
{
|
||||
executionId: sourceExecutionId,
|
||||
slotName: source.source_input_name,
|
||||
useRecordedBounds: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Raw targets are keys into lastNodeErrors, not surfacedNodeErrors. */
|
||||
function getRawClearTargets(
|
||||
executionId: NodeExecutionId,
|
||||
slotName: string
|
||||
): SlotNodeErrorClearTarget[] {
|
||||
return [
|
||||
{ executionId, slotName },
|
||||
...getLiftedErrorSourceTargets(executionId, slotName)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds recorded on the error win only for interior lifted targets, where
|
||||
* the caller's options describe the host widget rather than the interior
|
||||
* input. The base target keeps the caller's live widget bounds, which stay
|
||||
* authoritative when node definitions change after validation.
|
||||
*/
|
||||
function getTargetRangeOptions(
|
||||
errors: NodeError['errors'],
|
||||
fallback: { min?: number; max?: number }
|
||||
): { min?: number; max?: number } {
|
||||
for (const error of errors) {
|
||||
const { min, max } = getInputConfigBounds(error)
|
||||
if (min === undefined && max === undefined) continue
|
||||
return {
|
||||
min: typeof min === 'number' ? min : fallback.min,
|
||||
max: typeof max === 'number' ? max : fallback.max
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function isTargetStillOutOfRange(
|
||||
nodeErrors: Record<string, NodeError>,
|
||||
target: SlotNodeErrorClearTarget,
|
||||
value: number,
|
||||
callerOptions: { min?: number; max?: number }
|
||||
): boolean {
|
||||
const nodeError = nodeErrors[target.executionId]
|
||||
if (!nodeError) return false
|
||||
|
||||
const errors = nodeError.errors.filter(
|
||||
(error) => error.extra_info?.input_name === target.slotName
|
||||
)
|
||||
const options = target.useRecordedBounds
|
||||
? getTargetRangeOptions(errors, callerOptions)
|
||||
: callerOptions
|
||||
|
||||
return isValueStillOutOfRange(value, errors, options)
|
||||
}
|
||||
|
||||
function clearTargets(
|
||||
targets: { executionId: NodeExecutionId; slotName?: string }[]
|
||||
): void {
|
||||
if (!lastNodeErrors.value) return
|
||||
|
||||
let updated = lastNodeErrors.value
|
||||
for (const target of targets) {
|
||||
updated =
|
||||
clearSimpleNodeErrorsFromRecord(
|
||||
updated,
|
||||
target.executionId,
|
||||
target.slotName
|
||||
) ?? updated
|
||||
}
|
||||
|
||||
if (updated === lastNodeErrors.value) return
|
||||
lastNodeErrors.value = Object.keys(updated).length > 0 ? updated : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node's errors if they consist entirely of simple, auto-resolvable
|
||||
* types. When `slotName` is provided, only errors for that slot are checked
|
||||
* and boundary-lifted errors are also cleared through their raw interior
|
||||
* source. Node-scoped calls (no `slotName`) operate on the raw record key
|
||||
* only.
|
||||
*/
|
||||
function clearSimpleNodeErrors(
|
||||
executionId: NodeExecutionId,
|
||||
slotName?: string
|
||||
): void {
|
||||
clearTargets(
|
||||
slotName === undefined
|
||||
? [{ executionId }]
|
||||
: getRawClearTargets(executionId, slotName)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to clear an error for a given widget, but avoids clearing it if
|
||||
* the error is a range violation and the new value is still out of bounds.
|
||||
* The base target validates against the caller's live widget bounds; each
|
||||
* interior lifted target validates against its own recorded bounds, so a
|
||||
* boundary input fanning out to inputs with different constraints clears
|
||||
* only the targets the new value satisfies.
|
||||
*
|
||||
* Note: `value_not_in_list` errors are optimistically cleared without
|
||||
* list-membership validation because combo widgets constrain choices to
|
||||
@@ -138,16 +284,24 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
newValue: unknown,
|
||||
options?: { min?: number; max?: number }
|
||||
): void {
|
||||
if (typeof newValue === 'number' && lastNodeErrors.value) {
|
||||
const nodeErrors = lastNodeErrors.value[executionId]
|
||||
if (nodeErrors) {
|
||||
const errs = nodeErrors.errors.filter(
|
||||
(e) => e.extra_info?.input_name === widgetName
|
||||
)
|
||||
if (isValueStillOutOfRange(newValue, errs, options || {})) return
|
||||
}
|
||||
}
|
||||
clearSimpleNodeErrors(executionId, widgetName)
|
||||
const nodeErrors = lastNodeErrors.value
|
||||
if (!nodeErrors) return
|
||||
|
||||
const targets = getRawClearTargets(executionId, widgetName)
|
||||
const clearableTargets =
|
||||
typeof newValue === 'number'
|
||||
? targets.filter(
|
||||
(target) =>
|
||||
!isTargetStillOutOfRange(
|
||||
nodeErrors,
|
||||
target,
|
||||
newValue,
|
||||
options ?? {}
|
||||
)
|
||||
)
|
||||
: targets
|
||||
|
||||
clearTargets(clearableTargets)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +378,13 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
)
|
||||
|
||||
// Re-lifts only when the record changes; topology is assumed stable while errors are displayed.
|
||||
const surfacedNodeErrors = computed(() =>
|
||||
lastNodeErrors.value && app.isGraphReady
|
||||
? liftNodeErrorsToBoundary(app.rootGraph, lastNodeErrors.value)
|
||||
: lastNodeErrors.value
|
||||
)
|
||||
|
||||
const hasAnyError = computed(
|
||||
() =>
|
||||
hasExecutionError.value ||
|
||||
@@ -236,8 +397,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
const allErrorExecutionIds = computed<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
if (lastNodeErrors.value) {
|
||||
ids.push(...Object.keys(lastNodeErrors.value))
|
||||
if (surfacedNodeErrors.value) {
|
||||
ids.push(...Object.keys(surfacedNodeErrors.value))
|
||||
}
|
||||
if (lastExecutionError.value) {
|
||||
const nodeId = lastExecutionError.value.node_id
|
||||
@@ -279,8 +440,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
// Fall back to rootGraph when currentGraph hasn't been initialized yet
|
||||
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||
|
||||
if (lastNodeErrors.value) {
|
||||
for (const executionId of Object.keys(lastNodeErrors.value)) {
|
||||
if (surfacedNodeErrors.value) {
|
||||
for (const executionId of Object.keys(surfacedNodeErrors.value)) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
@@ -302,12 +463,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||
() => {
|
||||
if (!lastNodeErrors.value) return {}
|
||||
if (!surfacedNodeErrors.value) return {}
|
||||
|
||||
const map: Record<NodeLocatorId, NodeError> = {}
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
surfacedNodeErrors.value
|
||||
)) {
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (locatorId) {
|
||||
@@ -361,7 +522,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return errorAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
|
||||
useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore)
|
||||
|
||||
return {
|
||||
// Raw state
|
||||
@@ -380,6 +541,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
dismissErrorOverlay,
|
||||
|
||||
// Derived state
|
||||
surfacedNodeErrors,
|
||||
hasExecutionError,
|
||||
hasPromptError,
|
||||
hasNodeError,
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { Keybinding } from '@/platform/keybindings/types'
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
@@ -265,5 +267,9 @@ export interface ComfyExtension {
|
||||
*/
|
||||
onAuthUserLogout?(): Promise<void> | void
|
||||
|
||||
onNodeOutputsUpdated?(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
): void
|
||||
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
|
||||
type ExecutionErrorStore = ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
function createRequiredInputMissingNodeError(inputName: string): NodeError {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
export function seedRequiredInputMissingNodeError(
|
||||
store: ExecutionErrorStore,
|
||||
executionId: NodeExecutionId,
|
||||
inputName: string
|
||||
): void {
|
||||
store.lastNodeErrors = {
|
||||
[executionId]: createRequiredInputMissingNodeError(inputName)
|
||||
[executionId]: nodeError(
|
||||
[validationError('required_input_missing', inputName, {}, 'Missing', '')],
|
||||
'TestNode'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
30
src/utils/__tests__/nodeErrorHelpers.ts
Normal file
30
src/utils/__tests__/nodeErrorHelpers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
export function validationError(
|
||||
type: string,
|
||||
inputName?: string,
|
||||
extraInfo: Record<string, unknown> = {},
|
||||
message = `${type} message`,
|
||||
details = `${type} details`
|
||||
): NodeValidationError {
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
details,
|
||||
...(inputName
|
||||
? { extra_info: { ...extraInfo, input_name: inputName } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function nodeError(
|
||||
errors: NodeValidationError[],
|
||||
classType = 'InteriorNode'
|
||||
): NodeError {
|
||||
return {
|
||||
class_type: classType,
|
||||
dependent_outputs: [],
|
||||
errors
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,61 @@ export const SIMPLE_ERROR_TYPES = new Set([
|
||||
'required_input_missing'
|
||||
])
|
||||
|
||||
export type NodeValidationError = NodeError['errors'][number]
|
||||
|
||||
export const INPUT_LEVEL_VALIDATION_ERROR_TYPES = new Set([
|
||||
'required_input_missing',
|
||||
'bad_linked_input',
|
||||
'return_type_mismatch',
|
||||
'invalid_input_type',
|
||||
'value_smaller_than_min',
|
||||
'value_bigger_than_max',
|
||||
'value_not_in_list',
|
||||
'custom_validation_failed',
|
||||
'exception_during_inner_validation'
|
||||
])
|
||||
|
||||
export const NODE_LEVEL_VALIDATION_ERROR_TYPES = new Set([
|
||||
'exception_during_validation',
|
||||
'dependency_cycle'
|
||||
])
|
||||
|
||||
/** Decodes the `[type, { min, max, ... }]` tuple the backend attaches to range errors. */
|
||||
export function getInputConfigBounds(error: NodeValidationError): {
|
||||
min?: unknown
|
||||
max?: unknown
|
||||
} {
|
||||
const config = error.extra_info?.input_config
|
||||
if (!Array.isArray(config)) return {}
|
||||
|
||||
const bounds = config[1]
|
||||
if (!bounds || typeof bounds !== 'object') return {}
|
||||
|
||||
const { min, max } = bounds as { min?: unknown; max?: unknown }
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
export function isImageNotLoadedValidationError(
|
||||
error: NodeValidationError
|
||||
): boolean {
|
||||
return (
|
||||
error.type === 'custom_validation_failed' &&
|
||||
/invalid image file|\[errno 21\].*is a directory/i.test(
|
||||
[error.message, error.details].filter(Boolean).join('\n')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Anything not input-level (including unknown types) is node-level.
|
||||
export function isNodeLevelValidationError(
|
||||
error: NodeValidationError
|
||||
): boolean {
|
||||
return (
|
||||
!INPUT_LEVEL_VALIDATION_ERROR_TYPES.has(error.type) ||
|
||||
isImageNotLoadedValidationError(error)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `value` still violates a recorded range constraint.
|
||||
* Pass errors already filtered to the target widget (by `input_name`).
|
||||
|
||||
Reference in New Issue
Block a user