Compare commits

..

20 Commits

Author SHA1 Message Date
comfy-pr-bot
7f0c371138 [release] Increment version to 1.45.22 2026-07-15 05:12:02 +00:00
Comfy Org PR Bot
3f8dabb756 1.45.21 (#13680)
Patch version increment to 1.45.21

**Base branch:** `core/1.45`

Co-authored-by: jtydhr88 <860985+jtydhr88@users.noreply.github.com>
2026-07-15 00:54:14 -04:00
Terry Jia
5adef583a3 [backport core/1.45] fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631) (#13675)
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-15 00:04:14 -04:00
Terry Jia
e5200cdb93 [backport core/1.45] feat: add preview support for save text node (CORE:-176) (#12521) (#13672)
Co-authored-by: Yousef R. Gamaleldin <81116377+yousef-rafat@users.noreply.github.com>
2026-07-14 23:53:50 -04:00
Terry Jia
7e14c6e0fd [backport core/1.45] feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13376) (#13671) 2026-07-14 22:25:08 -04:00
Terry Jia
2ee671a0a3 [backport core/1.45] feat: wire up Save 3D (Advanced) node family (CORE-329) (#13330) (#13669) 2026-07-14 21:58:07 -04:00
Comfy Org PR Bot
7bd9f8b0d0 1.45.20 (#13243)
Patch version increment to 1.45.20

**Base branch:** `core/1.45`

Co-authored-by: jtydhr88 <860985+jtydhr88@users.noreply.github.com>
2026-06-29 19:55:13 -04:00
Terry Jia
1398f4c324 [backport core/1.45] FE-1150 feat(rightSidePanel): hide 3D viewport widgets from panel (#13206) (#13207)
Backport of https://github.com/Comfy-Org/ComfyUI_frontend/pull/13206 to
core/1.45
2026-06-27 04:31:10 -04:00
Comfy Org PR Bot
d54f2f1bbb [backport core/1.45] fix(billing): route subscription/sign-in/credit preconditions to modal, out of error panel (FE-878) (#13162)
Backport of #12785 to `core/1.45`

Automatically created by backport workflow.

---------

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:45:59 -07:00
Comfy Org PR Bot
89221e494d [backport core/1.45] fix: Add missing save nodes in text replacement module (CORE-301) (#12891)
Backport of #12837 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-25 16:52:15 -07:00
Comfy Org PR Bot
af64a2955b [backport core/1.45] fix: Remove duplicate app workflow validation (#12538)
Backport of #12208 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-25 16:49:25 -07:00
Comfy Org PR Bot
f62f1cf384 [backport core/1.45] Add special runtime error messaging (#13147)
Backport of #12466 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-25 16:45:31 -07:00
Comfy Org PR Bot
bcee98790f [backport core/1.45] [bugfix] Truncate long workspace names in workspace switcher (#13124)
Backport of #12762 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-25 13:37:32 -07:00
Terry Jia
be9ae499f7 [backport core/1.45] fix: derive CreateBoundingBoxes graph id from node instead of app (#13133) 2026-06-24 23:10:12 -04:00
Terry Jia
2d9442eeae [backport core/1.45] feat: add bounding boxes and colors widgets (CORE-292) (#12960) (#13130)
Backport of https://github.com/Comfy-Org/ComfyUI_frontend/pull/12960 to
core/1.45
2026-06-24 22:02:49 -04:00
Terry Jia
d1617d258c [backport core/1.45] feat: add HDR/EXR image viewer for SaveImageAdvanced outputs (#13049) (#13126)
Backport of https://github.com/Comfy-Org/ComfyUI_frontend/pull/13049 to
core/1.45
2026-06-24 22:01:43 -04:00
Comfy Org PR Bot
ce16c4b77f [backport core/1.45] General execution error messaging (#13118)
Backport of #12448 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-24 16:01:20 -07:00
Comfy Org PR Bot
7999894618 [backport core/1.45] refactor(load3d): extract Viewport3d base + SceneOverlay protocol (#13099)
Backport of #12987 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-24 14:33:36 -04:00
Comfy Org PR Bot
ea9579cacd [backport core/1.45] More robust drag cleanup (#13097)
Backport of #13084 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-24 11:27:24 -07:00
Comfy Org PR Bot
d2ce025986 [backport core/1.45] fix: skip templates modal when opening a template from the URL (#13029)
Backport of #12835 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Maanil Verma <vermaMaanil97@gmail.com>
2026-06-19 16:21:27 -07:00
140 changed files with 11093 additions and 1480 deletions

View File

@@ -0,0 +1,45 @@
import type { Page } from '@playwright/test'
function flagAttributeFor(testId: string) {
const encoded = Array.from(testId, (ch) =>
ch.charCodeAt(0).toString(16)
).join('')
return `data-flashed-${encoded}`
}
/**
* Flags the first time an element matching `[data-testid="<testId>"]` is
* present and rendered, sampled every frame via `requestAnimationFrame` from
* page load. Catches a dialog that mounts and unmounts within a few frames,
* which `toBeHidden()` (final state only) cannot.
*
* Must be called before navigation (e.g. before `comfyPage.setup()`).
*/
export async function trackElementFlash(
page: Page,
testId: string
): Promise<{ hasFlashed: () => Promise<boolean> }> {
const flagAttribute = flagAttributeFor(testId)
await page.addInitScript(
({ id, attribute }: { id: string; attribute: string }) => {
const sample = () => {
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
if (el instanceof HTMLElement) {
const rect = el.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
document.documentElement.setAttribute(attribute, 'true')
}
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
},
{ id: testId, attribute: flagAttribute }
)
return {
hasFlashed: async () =>
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
}
}

View File

@@ -0,0 +1,68 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { PromptResponse } from '@/schemas/apiSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
// Regression for #12840: a free-tier paywall on queue (`POST /prompt` 402 with
// `{ error: { type: 'PAYMENT_REQUIRED', message: 'Subscription required to
// queue workflows' } }`) is an account precondition. It must open its own modal
// and stay out of the error panel, instead of surfacing the raw backend string
// with non-actionable Find-on-GitHub / Copy actions.
test.describe('Subscription paywall on queue', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
async function mockQueueError(page: Page, error: PromptResponse['error']) {
const body: PromptResponse = { node_errors: {}, error }
await page.route('**/api/prompt', async (route) => {
await route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify(body)
})
})
}
test('keeps the subscription paywall out of the error panel', async ({
comfyPage
}) => {
await mockQueueError(comfyPage.page, {
type: 'PAYMENT_REQUIRED',
message: 'Subscription required to queue workflows',
details: ''
})
const queued = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.actionbar.queueButton.primaryButton.click()
await queued
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
})
test('still surfaces ordinary queue errors in the error panel', async ({
comfyPage
}) => {
await mockQueueError(comfyPage.page, {
type: 'server_error',
message: 'The server exploded',
details: ''
})
const queued = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.actionbar.queueButton.primaryButton.click()
await queued
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeVisible()
})
})

View File

@@ -4,6 +4,7 @@ import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
async function checkTemplateFileExists(
page: Page,
@@ -451,3 +452,32 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
}
)
})
test.describe(
'Templates deeplink (new user)',
{ tag: ['@slow', '@workflow'] },
() => {
test('templates dialog never flashes when first-time user opens a template link', async ({
comfyPage
}) => {
const templatesFlash = await trackElementFlash(
comfyPage.page,
TestIds.templates.content
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?template=default'
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
expect(await templatesFlash.hasFlashed()).toBe(false)
await expect(comfyPage.templates.content).toBeHidden()
})
}
)

View File

@@ -335,6 +335,30 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
})
test('pointerCancel stops autopan', async ({ comfyPage }) => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.header.click({ trial: true })
await comfyPage.page.mouse.down()
const getOffset = () => comfyPage.canvasOps.getOffset()
const initialOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
await test.step('move outside pan range and cancel drag', async () => {
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
await ksampler.header.evaluate((node) =>
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
)
})
const secondaryOffset = await getOffset()
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
await comfyPage.nextFrame()
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -12,6 +13,7 @@ test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
})
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
})
test('Copy button has correct aria-label', async ({ comfyPage }) => {

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.19",
"version": "1.45.22",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
escapeVueI18nMessageSyntax,
formatLocalizedMediumDate,
formatLocalizedNumber,
getFilePathSeparatorVariants,
@@ -414,15 +415,15 @@ describe('formatUtil', () => {
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
it('returns true for image/video/audio/3D/text', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
expect(isPreviewableMediaType('text')).toBe(true)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
it('returns false for other', () => {
expect(isPreviewableMediaType('other')).toBe(false)
})
})
@@ -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('')
})
})
})

View File

@@ -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
@@ -677,12 +711,7 @@ export function getMediaTypeFromFilename(
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
return mediaType !== 'other'
}
export function formatTime(seconds: number): string {

View File

@@ -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)
])
)
)

View File

@@ -0,0 +1,224 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
import boundingBoxes from '@/locales/en/main.json'
import type { BoundingBox } from '@/types/boundingBoxes'
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
boundingBoxes: boundingBoxes.boundingBoxes,
palette: { swatchTitle: 'Edit', addColor: 'Add' }
}
}
})
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...over
})
const fakeCtx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function prepCanvas(canvas: HTMLCanvasElement) {
Object.defineProperty(canvas, 'clientWidth', {
value: 100,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 100,
configurable: true
})
canvas.getContext = (() =>
fakeCtx) as unknown as HTMLCanvasElement['getContext']
canvas.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
canvas.setPointerCapture = () => {}
canvas.releasePointerCapture = () => {}
}
function renderWidget(modelValue: BoundingBox[]) {
const result = render(WidgetBoundingBoxes, {
props: { nodeId: '1', modelValue },
global: { plugins: [i18n] }
})
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
prepCanvas(canvas)
return { ...result, canvas }
}
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0] as BoundingBox[]
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: () => -1,
getInputNode: () => null
}
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('WidgetBoundingBoxes', () => {
it('renders the canvas and editor shell', () => {
renderWidget([])
expect(
screen.getByTestId('bounding-boxes').querySelector('canvas')
).not.toBeNull()
})
it('shows the region editor panel when a region is active', () => {
renderWidget([box()])
expect(screen.getByText('obj')).toBeTruthy()
expect(screen.getByText('text')).toBeTruthy()
})
it('reveals the text field after switching the region to text', async () => {
renderWidget([box()])
expect(
screen.queryByPlaceholderText('text to render (verbatim)')
).toBeNull()
await userEvent.click(screen.getByText('text'))
expect(
screen.getByPlaceholderText('text to render (verbatim)')
).toBeTruthy()
})
it('clears all regions via the clear button', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('Clear all'))
expect(lastBoxes(emitted)).toEqual([])
})
it('draws a region through canvas pointer events', async () => {
const { canvas, emitted } = renderWidget([])
await fireEvent.pointerDown(canvas, {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 1
})
await fireEvent.pointerMove(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
await fireEvent.pointerUp(canvas, {
clientX: 60,
clientY: 60,
pointerId: 1
})
expect(lastBoxes(emitted)).toHaveLength(1)
})
it('tracks focus and blur on the canvas', async () => {
const { canvas } = renderWidget([box()])
await fireEvent.focus(canvas)
await fireEvent.blur(canvas)
expect(canvas).toBeTruthy()
})
it('opens an inline editor on double click', async () => {
const { canvas, container } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
expect(container.querySelector('textarea')).not.toBeNull()
})
it('syncs description edits back to the model', async () => {
const { emitted } = renderWidget([box()])
await fireEvent.update(
screen.getByPlaceholderText('description of this region'),
'a caption'
)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
})
it('edits the text field once the region is a text region', async () => {
const { emitted } = renderWidget([box()])
await userEvent.click(screen.getByText('text'))
await fireEvent.update(
screen.getByPlaceholderText('text to render (verbatim)'),
'hello'
)
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
})
it('deletes the active region with the Delete key', async () => {
const { canvas, emitted } = renderWidget([box()])
await fireEvent.keyDown(canvas, { key: 'Delete' })
expect(lastBoxes(emitted)).toEqual([])
})
it('clears hover state on pointer leave', async () => {
const { canvas } = renderWidget([
box({ x: 10, y: 10, width: 256, height: 256 })
])
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
await fireEvent.pointerLeave(canvas)
expect(canvas).toBeTruthy()
})
it('commits the inline editor on blur', async () => {
const { canvas, container, emitted } = renderWidget([box()])
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
const editor = container.querySelector('textarea')!
await fireEvent.update(editor, 'committed')
await fireEvent.blur(editor)
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
})
})

View File

@@ -0,0 +1,205 @@
<template>
<div
class="widget-expands flex size-full flex-col gap-1 select-none"
data-testid="bounding-boxes"
@pointerdown.stop
>
<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
v-if="activeRegion"
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'obj'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('obj')"
>
{{ $t('boundingBoxes.typeObj') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
activeRegion.type === 'text'
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="setActiveType('text')"
>
{{ $t('boundingBoxes.typeText') }}
</Button>
</div>
<div
v-if="activeRegion.type === 'text'"
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.textLabel') }}
</span>
<Textarea
v-model="activeRegion.text"
:placeholder="$t('boundingBoxes.textPlaceholder')"
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
>
<span
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
>
{{ $t('boundingBoxes.descLabel') }}
</span>
<Textarea
v-model="activeRegion.desc"
:placeholder="$t('boundingBoxes.descPlaceholder')"
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
data-capture-wheel="true"
@update:model-value="syncState"
/>
</div>
<div class="flex items-center gap-2">
<span class="shrink-0 truncate text-sm text-muted-foreground">
{{ $t('boundingBoxes.colors') }}
</span>
<PaletteSwatchRow
v-model="activeRegion.palette"
:max="maxColors"
@update:model-value="syncState"
/>
</div>
</div>
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
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: string }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
const {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState,
grid
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
</script>

View File

@@ -0,0 +1,126 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import HdrViewerContent from './HdrViewerContent.vue'
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
vi.mock('@/composables/useHdrViewer', () => ({
useHdrViewer: () => holder.viewer,
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading', downloadImage: 'Download' },
hdrViewer: {
failedToLoad: 'Failed',
exposure: 'Exposure',
normalizeExposure: 'Auto exposure',
channel: 'Channel',
channels: {
rgb: 'RGB',
r: 'R',
g: 'G',
b: 'B',
a: 'Alpha',
luminance: 'Luminance'
},
sourceGamut: 'Source gamut',
dither: 'Dither',
clipWarnings: 'Clip warnings',
fitView: 'Fit',
histogram: 'Histogram',
resolution: 'Resolution',
min: 'Min',
max: 'Max',
mean: 'Mean',
stdDev: 'Std dev',
nan: 'NaN',
inf: 'Inf'
}
}
}
})
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
exposureStops: ref(0),
dither: ref(true),
clipWarnings: ref(false),
gamut: ref('sRGB'),
channel: ref('r'),
loading: ref(false),
error: ref(null),
dimensions: ref('512 x 512'),
stats: ref({
min: 0,
max: 4,
mean: 0.5,
stdDev: 0.2,
nanCount: 2,
infCount: 1
}),
histogram: ref(new Uint32Array([1, 2, 3, 4])),
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
mount: vi.fn(),
dispose: vi.fn(),
fitView: vi.fn(),
normalizeExposure: vi.fn(),
...overrides
}
}
function renderViewer() {
return render(HdrViewerContent, {
props: { imageUrl: '/api/view?filename=out.exr' },
global: { plugins: [i18n], stubs: { Button: true } }
})
}
describe('HdrViewerContent', () => {
beforeEach(() => {
holder.viewer = makeViewer()
})
it('renders the full statistics set including NaN/Inf', () => {
renderViewer()
for (const label of [
'Resolution',
'Min',
'Max',
'Mean',
'Std dev',
'NaN',
'Inf'
]) {
screen.getByText(label)
}
})
it('shows the pixel readout when a pixel is hovered', () => {
renderViewer()
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
})
it('colors the histogram according to the selected channel', () => {
holder.viewer = makeViewer({ channel: ref('g') })
const { container } = renderViewer()
const path = container.querySelector('svg path')
expect(path?.getAttribute('class')).toContain('text-green-500')
})
it('renders an option for each channel mode', () => {
renderViewer()
expect(
screen.getByRole('option', { name: 'Luminance' })
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,258 @@
<template>
<div class="flex size-full bg-base-background">
<div class="relative flex-1">
<div
ref="containerRef"
class="absolute size-full"
data-testid="hdr-viewer-canvas"
/>
<div
v-if="viewer.loading.value"
class="absolute inset-0 flex items-center justify-center text-base-foreground"
>
{{ $t('g.loading') }}...
</div>
<div
v-else-if="viewer.error.value"
role="alert"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
>
<i class="icon-[lucide--image-off] size-12" />
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
</div>
<div
v-if="viewer.pixel.value"
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
data-testid="hdr-pixel-readout"
>
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
<div>
{{ formatNum(viewer.pixel.value.r) }}
{{ formatNum(viewer.pixel.value.g) }}
{{ formatNum(viewer.pixel.value.b) }}
<template v-if="viewer.pixel.value.a !== null">
{{ formatNum(viewer.pixel.value.a) }}
</template>
</div>
</div>
</div>
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
<input
v-model.number="viewer.exposureStops.value"
type="range"
min="-10"
max="10"
step="0.1"
class="w-full"
:aria-label="$t('hdrViewer.exposure')"
/>
</div>
<Button
variant="secondary"
class="w-full"
@click="viewer.normalizeExposure"
>
{{ $t('hdrViewer.normalizeExposure') }}
</Button>
</div>
<div class="space-y-4 p-2">
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.channel') }}</label>
<select
v-model="viewer.channel.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.channel')"
>
<option v-for="mode in channelModes" :key="mode" :value="mode">
{{ channelLabels[mode] }}
</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
<select
v-model="viewer.gamut.value"
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
:aria-label="$t('hdrViewer.sourceGamut')"
>
<option v-for="name in gamutNames" :key="name" :value="name">
{{ name }}
</option>
</select>
</div>
</div>
<div class="space-y-4 p-2">
<div class="flex items-center gap-2">
<input
id="hdr-dither"
v-model="viewer.dither.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-dither" class="cursor-pointer">
{{ $t('hdrViewer.dither') }}
</label>
</div>
<div class="flex items-center gap-2">
<input
id="hdr-clip"
v-model="viewer.clipWarnings.value"
type="checkbox"
class="size-4 cursor-pointer accent-node-component-surface-highlight"
/>
<label for="hdr-clip" class="cursor-pointer">
{{ $t('hdrViewer.clipWarnings') }}
</label>
</div>
</div>
<div v-if="histogramPath" class="space-y-2 p-2">
<label>{{ $t('hdrViewer.histogram') }}</label>
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
>
<path
:d="histogramPath"
:class="histogramColorClass"
fill="currentColor"
fill-opacity="0.5"
stroke="none"
/>
</svg>
</div>
<div
v-if="viewer.stats.value"
class="space-y-1 p-2 text-xs tabular-nums"
>
<div v-if="viewer.dimensions.value" class="flex justify-between">
<span>{{ $t('hdrViewer.resolution') }}</span>
<span>{{ viewer.dimensions.value }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.min') }}</span>
<span>{{ formatNum(viewer.stats.value.min) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.max') }}</span>
<span>{{ formatNum(viewer.stats.value.max) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.mean') }}</span>
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('hdrViewer.stdDev') }}</span>
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
</div>
<div
v-if="viewer.stats.value.nanCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.nan') }}</span>
<span>{{ viewer.stats.value.nanCount }}</span>
</div>
<div
v-if="viewer.stats.value.infCount"
class="flex justify-between text-error"
>
<span>{{ $t('hdrViewer.inf') }}</span>
<span>{{ viewer.stats.value.infCount }}</span>
</div>
</div>
</div>
</div>
<div class="p-4">
<div class="flex gap-2">
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
{{ $t('hdrViewer.fitView') }}
</Button>
<Button variant="secondary" class="flex-1" @click="handleDownload">
{{ $t('g.downloadImage') }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import type { ChannelMode } from '@/composables/useHdrViewer'
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
import { histogramToPath } from '@/utils/histogramUtil'
const { imageUrl } = defineProps<{ imageUrl: string }>()
const { t } = useI18n()
const viewer = useHdrViewer()
const gamutNames = GAMUT_NAMES
const channelModes = CHANNEL_MODES
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const exposureLabel = computed(() => {
const value = viewer.exposureStops.value
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
})
const histogramPath = computed(() =>
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
)
const histogramColorClass = computed(() => {
switch (viewer.channel.value) {
case 'r':
return 'text-red-500'
case 'g':
return 'text-green-500'
case 'b':
return 'text-blue-500'
default:
return 'text-base-foreground'
}
})
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
rgb: t('hdrViewer.channels.rgb'),
r: t('hdrViewer.channels.r'),
g: t('hdrViewer.channels.g'),
b: t('hdrViewer.channels.b'),
a: t('hdrViewer.channels.a'),
luminance: t('hdrViewer.channels.luminance')
}))
function formatNum(value: number): string {
if (!Number.isFinite(value)) return String(value)
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
? value.toExponential(3)
: value.toFixed(4)
}
function handleDownload() {
downloadFile(toFullResolutionUrl(imageUrl))
}
onMounted(() => {
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
})
</script>

View File

@@ -0,0 +1,66 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderRow(modelValue: string[], max = 5) {
return render(PaletteSwatchRow, {
props: { modelValue, max },
global: { plugins: [i18n] }
})
}
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
const calls = emitted()['update:modelValue']
return calls[calls.length - 1][0]
}
describe('PaletteSwatchRow', () => {
it('renders one swatch per color', () => {
const { container } = renderRow(['#ff0000', '#00ff00'])
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button', { name: '+' }))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
it('removes a color on right click', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
expect(lastEmit(emitted)).toEqual(['#00ff00'])
})
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
})
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 () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
button: 0,
clientX: 5,
clientY: 5
})
expect(emitted()['update:modelValue']).toBeUndefined()
})
})

View File

@@ -0,0 +1,52 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<ColorPicker
v-for="(hex, i) in modelValue"
: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"
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
:title="t('palette.addColor')"
@click="addColor"
>
+
</button>
</div>
</template>
<script setup lang="ts">
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 }>()
const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
modelValue,
container
})
</script>

View File

@@ -0,0 +1,54 @@
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WidgetColors from './WidgetColors.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
})
function renderWidget(modelValue: string[], widget?: { name: string }) {
return render(WidgetColors, {
props: { modelValue, widget },
global: { plugins: [i18n] }
})
}
const cleanups: Array<() => void> = []
afterEach(() => {
while (cleanups.length) cleanups.pop()?.()
})
describe('WidgetColors', () => {
it('renders the palette swatch row for each color', () => {
renderWidget(['#ff0000', '#00ff00'])
const root = screen.getByTestId('colors')
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
})
it('shows the widget name as an inline label', () => {
renderWidget(['#ff0000'], { name: 'color_palette' })
expect(screen.getByText('color_palette')).toBeInTheDocument()
})
it('emits an updated palette when a color is added', async () => {
const { emitted } = renderWidget([])
await userEvent.click(screen.getByRole('button'))
const calls = emitted()['update:modelValue'] as unknown[][]
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
})
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
const { container } = renderWidget(['#ff0000'])
const onDocMove = vi.fn()
document.addEventListener('pointermove', onDocMove)
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
expect(onDocMove).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,29 @@
<template>
<div
class="flex size-full items-center gap-2"
data-testid="colors"
@pointerdown.stop
>
<span
v-if="widget?.name"
class="shrink-0 truncate text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</span>
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
</div>
</template>
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import PaletteSwatchRow from './PaletteSwatchRow.vue'
const MAX_COLORS = 16
const { widget } = defineProps<{
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
}>()
const modelValue = defineModel<string[]>({ default: () => [] })
</script>

View File

@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
0
)
expect(
screen.getByText(
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
@@ -165,7 +167,10 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
screen.getByText('Node threw an error during execution.')
).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
})
@@ -244,9 +249,9 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
})
it('shows missing model Refresh in the section header when no model is downloadable', async () => {

View File

@@ -29,17 +29,62 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/i18n', () => ({
te: vi.fn(() => false),
st: vi.fn((_key: string, fallback: string) => fallback),
t: vi.fn((key: string, params?: { count?: number }) => {
if (key === 'errorOverlay.missingModels') {
const count = params?.count ?? 0
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return key
})
}))
vi.mock('@/i18n', () => {
const messages: Record<string, string> = {
'errorCatalog.validationErrors.required_input_missing.title':
'Missing connection',
'errorCatalog.validationErrors.required_input_missing.message':
'Required input slots have no connection feeding them.',
'errorCatalog.validationErrors.required_input_missing.details':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.required_input_missing.itemLabel':
'{nodeName} - {inputName}',
'errorCatalog.validationErrors.required_input_missing.toastTitle':
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
'errorCatalog.runtimeErrors.execution_failed.message':
'Node threw an error during execution.',
'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.execution_failed.toastTitle':
'{nodeName} failed',
'errorCatalog.runtimeErrors.execution_failed.toastMessage':
'This node threw an error during execution. Check its inputs or try a different configuration.',
'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.message':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.toastMessage':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
}
const interpolate = (
message: string,
params?: Record<string, string | number>
) =>
message.replace(/\{(\w+)\}/g, (match, paramName) =>
params?.[paramName] === undefined ? match : String(params[paramName])
)
return {
te: vi.fn((key: string) => key in messages),
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
t: vi.fn((key: string, params?: Record<string, string | number>) => {
if (key === 'errorOverlay.missingModels') {
const count = Number(params?.count ?? 0)
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
}
return interpolate(messages[key] ?? key, params)
})
}
})
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
@@ -128,6 +173,7 @@ function createErrorGroups() {
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockIsCloud.value = false
})
describe('missingPackGroups', () => {
@@ -391,7 +437,8 @@ describe('useErrorGroups', () => {
)
})
it('includes execution error from runtime errors', async () => {
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
@@ -400,7 +447,7 @@ describe('useErrorGroups', () => {
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
exception_message: 'mat1 and mat2 shapes cannot be multiplied',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
@@ -412,10 +459,53 @@ describe('useErrorGroups', () => {
)
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
'KSampler failed'
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
message: 'RuntimeError: mat1 and mat2 shapes cannot be multiplied',
details: 'line 1\nline 2',
isRuntimeError: true,
exceptionType: 'RuntimeError',
catalogId: 'execution_failed',
displayTitle: 'Execution failed',
displayMessage: 'Node threw an error during execution.',
displayItemLabel: 'KSampler',
toastTitle: 'KSampler failed',
toastMessage:
'This node threw an error during execution. Check its inputs or try a different configuration.'
})
})
it('adds display fields for targeted runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'torch.OutOfMemoryError',
exception_message:
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const error = execGroup.cards[0].errors[0]
expect(error.message).toContain('torch.OutOfMemoryError:')
expect(error.catalogId).toBe('out_of_memory')
expect(error.displayMessage).toBe(
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
)
expect(error.displayItemLabel).toBe('KSampler')
expect(error.toastTitle).toBe('Generation failed')
})
it('includes prompt error when present', async () => {
@@ -428,7 +518,8 @@ describe('useErrorGroups', () => {
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeDefined()
})

View File

@@ -417,12 +417,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (!executionErrorStore.lastExecutionError) return
const e = executionErrorStore.lastExecutionError
const resolvedDisplay = resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName: e.node_type,
isCloud
})
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
@@ -434,7 +428,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolvedDisplay
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
}
],
filterBySelection

View File

@@ -38,7 +38,11 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
!(
w.options?.canvasOnly ||
w.options?.hidden ||
w.options?.hideInPanel
) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }

View File

@@ -266,6 +266,7 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
!(
w.options?.canvasOnly ||
w.options?.hidden ||
w.options?.hideInPanel ||
(w.options?.advanced && !includesAdvanced.value)
)
)

View File

@@ -49,6 +49,7 @@
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
</template>
</div>
@@ -75,6 +76,7 @@ import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{

View File

@@ -0,0 +1,21 @@
<template>
<article
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
>
<span v-if="hasError" class="text-muted-foreground">
{{ $t('g.textFailedToLoad') }}
</span>
<template v-else>{{ textContent }}</template>
</article>
</template>
<script setup lang="ts">
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { ResultItemImpl } from '@/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl
}>()
const { textContent, hasError } = useTextFileContent(() => result)
</script>

View File

@@ -13,19 +13,26 @@ import { cn } from '@comfyorg/tailwind-utils'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
const { alpha = true } = defineProps<{
class?: string
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)
}
})
@@ -64,48 +71,50 @@ const isOpen = ref(false)
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
: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',
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"
: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',
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
@@ -118,6 +127,7 @@ const isOpen = ref(false)
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
:alpha
/>
</PopoverContent>
</PopoverPortal>

View File

@@ -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>

View File

@@ -0,0 +1,258 @@
import { describe, expect, it } from 'vitest'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { HitMode, Region } from './boundingBoxesUtil'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from './boundingBoxesUtil'
const region = (over: Partial<Region> = {}): Region => ({
x: 0.2,
y: 0.2,
w: 0.2,
h: 0.2,
type: 'obj',
text: '',
desc: '',
palette: [],
...over
})
describe('applyDrag', () => {
it('moves without resizing and keeps width/height', () => {
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
expect(out.x).toBeCloseTo(0.3)
expect(out.y).toBeCloseTo(0.3)
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.2)
})
it('clamps a move so the box stays inside the unit square', () => {
const out = applyDrag(
'move',
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
0.5,
0.5
)
expect(out.x).toBeCloseTo(0.8)
expect(out.y).toBeCloseTo(0.8)
})
it('grows from the bottom-right for draw and resize-br', () => {
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
const out = applyDrag(
mode,
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
0.1,
0.2
)
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
expect(out.w).toBeCloseTo(0.2)
expect(out.h).toBeCloseTo(0.3)
}
})
it('moves the top-left corner on resize-tl', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.1,
0.1
)
expect(out.x).toBeCloseTo(0.6)
expect(out.y).toBeCloseTo(0.6)
expect(out.w).toBeCloseTo(0.1)
expect(out.h).toBeCloseTo(0.1)
})
it('normalizes a corner drag that inverts the box', () => {
const out = applyDrag(
'resize-tl',
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
0.3,
0
)
expect(out.x).toBeCloseTo(0.7)
expect(out.w).toBeCloseTo(0.1)
expect(out.y).toBeCloseTo(0.5)
expect(out.h).toBeCloseTo(0.2)
})
it('resizes single edges', () => {
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
expect(top.y).toBeCloseTo(0.5)
expect(top.h).toBeCloseTo(0.1)
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
expect(left.x).toBeCloseTo(0.5)
expect(left.w).toBeCloseTo(0.1)
})
})
describe('boxesAt', () => {
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
it('detects a corner handle', () => {
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
})
it('detects an interior move', () => {
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
})
it('returns nothing when the pointer misses every box', () => {
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
})
it('brings the active box to the front of overlapping candidates', () => {
const overlapping: Region[] = [
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
]
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
expect(hits).toHaveLength(2)
expect(hits[0].index).toBe(1)
})
})
describe('tagRects', () => {
const measure = (s: string) => s.length * 7
it('places the first tag at the top-left corner', () => {
const rects = tagRects(
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
100,
100,
measure
)
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
expect(rects[0].w).toBe(measure('01') + 8)
})
it('moves a colliding tag to a different corner', () => {
const boxes = [
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
]
const rects = tagRects(boxes, 100, 100, measure)
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
expect(sameSpot).toBe(false)
})
})
describe('fromBoundingBoxes', () => {
it('converts pixel boxes to normalized regions with metadata', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
x: 0.1,
y: 0.2,
w: 0.3,
h: 0.4,
type: 'text',
text: 'hi',
desc: 'd',
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({
type: 'obj',
text: '',
desc: '',
palette: []
})
})
it('drops entries that are not bounding boxes', () => {
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
})
it('guards against zero dimensions', () => {
const boxes: BoundingBox[] = [
{
x: 5,
y: 5,
width: 5,
height: 5,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
x: 5,
y: 5,
w: 5,
h: 5
})
})
})
describe('toBoundingBoxes', () => {
it('rounds normalized regions back to pixels and copies the palette', () => {
const palette = ['#abc']
const regions: Region[] = [
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
]
const [box] = toBoundingBoxes(regions, 1000, 1000)
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
expect(box.metadata.palette).toEqual(['#abc'])
expect(box.metadata.palette).not.toBe(palette)
})
it('round-trips from pixels to regions and back', () => {
const boxes: BoundingBox[] = [
{
x: 100,
y: 200,
width: 300,
height: 400,
metadata: { type: 'obj', text: '', desc: '', palette: [] }
}
]
const restored = toBoundingBoxes(
fromBoundingBoxes(boxes, 1000, 1000),
1000,
1000
)
expect(restored).toEqual(boxes)
})
})

View File

@@ -0,0 +1,260 @@
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
export type HitMode =
| 'move'
| 'draw'
| 'resize-tl'
| 'resize-tr'
| 'resize-bl'
| 'resize-br'
| 'resize-t'
| 'resize-b'
| 'resize-l'
| 'resize-r'
export interface Region extends BoundingBoxMetadata {
x: number
y: number
w: number
h: number
}
interface BoxCandidate {
index: number
mode: HitMode
}
interface TagRect {
x: number
y: number
w: number
h: number
tag: string
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function normalizeBox(b: Region): Region {
let { x, y, w, h } = b
if (w < 0) {
x += w
w = -w
}
if (h < 0) {
y += h
h = -h
}
x = clamp01(x)
y = clamp01(y)
w = Math.min(w, 1 - x)
h = Math.min(h, 1 - y)
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
}
function rectHitTest(
mx: number,
my: number,
x1: number,
y1: number,
x2: number,
y2: number,
rx: number,
ry: number
): HitMode | null {
const h = (cx: number, cy: number) =>
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
if (h(x1, y1)) return 'resize-tl'
if (h(x2, y1)) return 'resize-tr'
if (h(x1, y2)) return 'resize-bl'
if (h(x2, y2)) return 'resize-br'
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
return null
}
export function applyDrag(
mode: HitMode,
start: Region,
dx: number,
dy: number
): Region {
let { x, y, w, h } = start
switch (mode) {
case 'move':
x += dx
y += dy
x = clamp01(Math.min(x, 1 - w))
y = clamp01(Math.min(y, 1 - h))
break
case 'draw':
case 'resize-br':
w += dx
h += dy
break
case 'resize-tl':
x += dx
y += dy
w -= dx
h -= dy
break
case 'resize-tr':
y += dy
w += dx
h -= dy
break
case 'resize-bl':
x += dx
w -= dx
h += dy
break
case 'resize-t':
y += dy
h -= dy
break
case 'resize-b':
h += dy
break
case 'resize-l':
x += dx
w -= dx
break
case 'resize-r':
w += dx
break
}
return mode === 'move'
? { ...start, x, y }
: normalizeBox({ ...start, x, y, w, h })
}
export function boxesAt(
regions: readonly Region[],
mxN: number,
myN: number,
handlePx: number,
logW: number,
logH: number,
activeIdx: number
): BoxCandidate[] {
const rx = handlePx / Math.max(1, logW)
const ry = handlePx / Math.max(1, logH)
const res: BoxCandidate[] = []
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
if (mode) res.push({ index: i, mode })
}
const ai = res.findIndex((c) => c.index === activeIdx)
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
return res
}
export function tagRects(
regions: readonly Region[],
logW: number,
logH: number,
measureWidth: (s: string) => number,
height = 14
): TagRect[] {
const placed: TagRect[] = []
const rects: TagRect[] = []
const hits = (a: TagRect, b: TagRect) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
for (let i = 0; i < regions.length; i++) {
const b = regions[i]
const x1 = b.x * logW
const y1 = b.y * logH
const x2 = (b.x + b.w) * logW
const y2 = (b.y + b.h) * logH
const tag = String(i + 1).padStart(2, '0')
const w = measureWidth(tag) + 8
let pick: [number, number] = [x1, y1]
for (const [cx, cy] of [
[x1, y1],
[x2 - w, y1],
[x2 - w, y2 - height],
[x1, y2 - height]
] as const) {
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
if (!placed.some((p) => hits(candidate, p))) {
pick = [cx, cy]
break
}
}
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
placed.push(r)
rects[i] = r
}
return rects
}
function isBoundingBox(b: unknown): b is BoundingBox {
if (!b || typeof b !== 'object') return false
const box = b as Record<string, unknown>
return (
typeof box.x === 'number' &&
typeof box.y === 'number' &&
typeof box.width === 'number' &&
typeof box.height === 'number'
)
}
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,
height: number
): Region[] {
const w = width || 1
const h = height || 1
return boxes.filter(isBoundingBox).map((box) => {
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
return {
x: box.x / w,
y: box.y / h,
w: box.width / w,
h: box.height / h,
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: normalizePalette(meta.palette)
}
})
}
export function toBoundingBoxes(
regions: readonly Region[],
width: number,
height: number
): BoundingBox[] {
return regions.map((r) => ({
x: Math.round(r.x * width),
y: Math.round(r.y * height),
width: Math.round(r.w * width),
height: Math.round(r.h * height),
metadata: {
type: r.type,
text: r.text,
desc: r.desc,
palette: r.palette.slice()
}
}))
}

View File

@@ -0,0 +1,463 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref, ShallowRef } from 'vue'
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
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: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
arc: () => {},
fill: () => {},
rect: () => {},
clip: () => {},
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0
} as unknown as CanvasRenderingContext2D
function makeCanvas(): HTMLCanvasElement {
const el = document.createElement('canvas')
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
}) as DOMRect
el.focus = () => {}
el.setPointerCapture = () => {}
el.releasePointerCapture = () => {}
return el
}
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: '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,
over: Partial<PointerEvent> = {}
) =>
({
button: 0,
clientX,
clientY,
altKey: false,
pointerId: 1,
preventDefault: () => {},
stopPropagation: () => {},
...over
}) as unknown as PointerEvent
const flush = async () => {
await Promise.resolve()
await nextTick()
}
type Api = ReturnType<typeof useBoundingBoxes>
interface Captured extends Api {
canvasEl: ShallowRef<HTMLCanvasElement | null>
modelValue: Ref<BoundingBox[]>
}
const modelBoxes = (c: Captured) => c.modelValue.value
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
setup() {
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
const modelValue = ref(initial)
const api = useBoundingBoxes('1', {
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
})
captured = { canvasEl, modelValue, ...api }
return () => h('div')
}
})
render(Harness)
captured!.canvasEl.value = makeCanvas()
return captured!
}
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
x: 51,
y: 51,
width: 256,
height: 256,
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
...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
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useBoundingBoxes initialization', () => {
it('derives regions from the initial model value', () => {
const c = setup([box()])
expect(c.hasRegions.value).toBe(true)
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
})
it('exposes an aspect-ratio canvas style from the node width/height', () => {
const c = setup()
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
})
it('starts with no active region when empty', () => {
const c = setup()
expect(c.hasRegions.value).toBe(false)
expect(c.activeRegion.value).toBeNull()
})
})
describe('useBoundingBoxes drawing', () => {
it('draws a new region and syncs it to the model value', 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].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
const c = setup([box()])
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
})
})
describe('useBoundingBoxes region editing', () => {
it('changes the active region type', async () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(modelBoxes(c)[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
const c = setup([box()])
c.onCanvasKeyDown({
key: 'Delete',
preventDefault: () => {},
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
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(modelBoxes(c)).toHaveLength(0)
expect(lastIncomingOf(node)).toEqual([])
})
})
describe('useBoundingBoxes inline editor', () => {
it('opens on double click and commits the description', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
expect(c.inlineEditor.value).not.toBeNull()
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
it('closes the inline editor on Escape', async () => {
const c = setup([box()])
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
await flush()
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
expect(c.inlineEditor.value).toBeNull()
})
})
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 })])
expect(c.canvasCursor.value).toBe('crosshair')
c.onCanvasPointerMove(pe(15, 15))
await flush()
expect(c.canvasCursor.value).toBe('pointer')
})
})

View File

@@ -0,0 +1,778 @@
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'
import {
applyDrag,
boxesAt,
fromBoundingBoxes,
tagRects,
toBoundingBoxes
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import type {
HitMode,
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'
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
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
style: Record<string, string>
index: number
}
interface UseBoundingBoxesOptions {
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
modelValue: Ref<BoundingBox[]>
}
export function useBoundingBoxes(
nodeId: string,
{
canvasEl,
canvasContainer,
inlineEditorEl,
modelValue
}: UseBoundingBoxesOptions
) {
const focused = ref(false)
const drawing = ref(false)
const dragMode = ref<HitMode | null>(null)
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
const boxAtStart = ref<Region | null>(null)
const hoverIndex = ref<number | null>(null)
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)
const litegraphNode = computed(() =>
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
)
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const isNodeSelected = computed(() =>
selectedNodeIds.value.has(String(nodeId))
)
function dimWidget(name: 'width' | 'height'): number | undefined {
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
return typeof v === 'number' && v > 0 ? v : undefined
}
const widthValue = computed(() => dimWidget('width') ?? 1024)
const heightValue = computed(() => dimWidget('height') ?? 1024)
const state = ref({
regions: fromBoundingBoxes(
modelValue.value ?? [],
widthValue.value,
heightValue.value
)
})
const activeIndex = ref(state.value.regions.length ? 0 : -1)
const aspectRatio = computed(
() => `${widthValue.value} / ${heightValue.value}`
)
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
const activeRegion = computed(() =>
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
)
const hasRegions = computed(() => state.value.regions.length > 0)
function clampToCanvas(n: number) {
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 }
}
function pointerNorm(e: PointerEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
let rafHandle = 0
function requestDraw() {
if (rafHandle) return
rafHandle = requestAnimationFrame(() => {
rafHandle = 0
drawCanvas()
})
}
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
return ctx.measureText(s).width
}
function drawCanvas() {
const el = canvasEl.value
if (!el) return
const { w: W, h: H } = logicalSize()
const dpr = window.devicePixelRatio || 1
const bw = Math.max(1, Math.round(W * dpr))
const bh = Math.max(1, Math.round(H * dpr))
if (el.width !== bw || el.height !== bh) {
el.width = bw
el.height = bh
}
const ctx = el.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
if (bgImage.value) {
ctx.drawImage(bgImage.value, 0, 0, W, H)
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
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
.map((_, i) => i)
.filter((i) => i !== aIdx)
.reverse()
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
ctx.font = 'bold 11px monospace'
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
for (const i of order) {
const b = state.value.regions[i]
const active = i === aIdx
const pal = (b.palette || []).filter(Boolean)
const col = pal.length ? pal[0] : '#8c8c8c'
const x1 = b.x * W
const y1 = b.y * H
const x2 = (b.x + b.w) * W
const y2 = (b.y + b.h) * H
const w = x2 - x1
const h = y2 - y1
const hovered = i === hoverIndex.value || active
if (active) {
ctx.fillStyle = 'rgba(26,26,26,0.88)'
ctx.fillRect(x1, y1, w, h)
}
ctx.fillStyle = col + (hovered ? '3a' : '22')
ctx.fillRect(x1, y1, w, h)
const lw = active ? 2 : hovered ? 1.5 : 1
ctx.strokeStyle = col
ctx.lineWidth = lw
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
if (pal.length) {
const sw = w / pal.length
const sh = 7
for (let p = 0; p < pal.length; p++) {
const sx = x1 + Math.round(p * sw)
ctx.fillStyle = pal[p]
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
}
}
ctx.save()
ctx.beginPath()
ctx.rect(x1, y1, w, h)
ctx.clip()
let body = b.desc || ''
if (b.type === 'text' && b.text)
body = `"${b.text}"` + (body ? `${body}` : '')
if (body) {
ctx.font = '12px monospace'
ctx.fillStyle = readableTextColor(col)
const pad = 4
const lh = 14
let ty = y1 + 15 + 12
for (const line of wrapLines(ctx, body, w - pad * 2)) {
if (ty > y1 + h) break
ctx.fillText(line, x1 + pad, ty)
ty += lh
}
}
const tr = tag_rects[i]
ctx.font = 'bold 11px monospace'
ctx.fillStyle = col
ctx.fillRect(tr.x, tr.y, tr.w, 14)
if (i === hoverTagIndex.value) {
ctx.fillStyle = 'rgba(255,255,255,0.25)'
ctx.fillRect(tr.x, tr.y, tr.w, 14)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
}
ctx.fillStyle = textOnColor(col)
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
ctx.restore()
}
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxW: number
): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/)) {
if (!word) continue
const test = line ? `${line} ${word}` : word
if (line && ctx.measureText(test).width > maxW) {
out.push(line)
line = word
} else {
line = test
}
}
out.push(line)
}
return out
}
const hitTestPoint = (mN: { x: number; y: number }) => {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
const titleAt = (mN: { x: number; y: number }) => {
const el = canvasEl.value
if (!el) return null
const ctx = el.getContext('2d')
if (!ctx) return null
const { w: W, h: H } = logicalSize()
const rects = tagRects(state.value.regions, W, H, (s) =>
measureWidth(ctx, s)
)
const px = mN.x * W
const py = mN.y * H
for (let i = state.value.regions.length - 1; i >= 0; i--) {
const r = rects[i]
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
return i
}
return null
}
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
if (!cands.length) return null
const activeResize = cands.find(
(c) => c.index === activeIndex.value && c.mode !== 'move'
)
if (activeResize && !cycle) return activeResize
const ti = titleAt(mN)
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
if (cycle && cands.length > 1) {
const pos = cands.findIndex((c) => c.index === activeIndex.value)
return cands[(pos + 1) % cands.length]
}
return (
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
cands[0]
)
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
canvasEl.value?.focus()
hoverTagIndex.value = null
hoverIndex.value = null
const mN = pointerNorm(e)
const hit = pickForSelection(mN, e.altKey)
if (hit) {
activeIndex.value = hit.index
dragMode.value = hit.mode
boxAtStart.value = { ...state.value.regions[hit.index] }
} else {
dragMode.value = 'draw'
const nb: Region = {
x: mN.x,
y: mN.y,
w: 0,
h: 0,
type: 'obj',
text: '',
desc: '',
palette: []
}
state.value.regions.push(nb)
activeIndex.value = state.value.regions.length - 1
boxAtStart.value = { ...nb }
}
drawing.value = true
dragStartNorm.value = mN
canvasEl.value?.setPointerCapture(e.pointerId)
e.preventDefault()
requestDraw()
}
function onDocPointerMove(e: PointerEvent) {
if (
!drawing.value ||
!boxAtStart.value ||
!dragStartNorm.value ||
!dragMode.value
)
return
const mN = pointerNorm(e)
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] = snapRegion(nb, dragMode.value)
requestDraw()
}
function onDocPointerUp(e: PointerEvent) {
if (!drawing.value) return
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)) {
removeRegion(activeIndex.value)
}
syncState()
}
function onCanvasPointerMove(e: PointerEvent) {
if (drawing.value) onDocPointerMove(e)
else onPointerMove(e)
}
function onPointerMove(e: PointerEvent) {
if (drawing.value) return
const mN = pointerNorm(e)
const ti = titleAt(mN)
const hit = hitTestPoint(mN)
const hb = ti !== null ? ti : hit ? hit.index : null
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
hoverTagIndex.value = ti
hoverIndex.value = hb
requestDraw()
}
}
function onPointerLeave() {
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
hoverTagIndex.value = null
hoverIndex.value = null
requestDraw()
}
}
const canvasCursor = computed(() =>
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
)
function onDoubleClick(e: MouseEvent) {
e.preventDefault()
const mN = pointerNormFromMouse(e)
const { w: W, h: H } = logicalSize()
const cands = boxesAt(
state.value.regions,
mN.x,
mN.y,
HANDLE_PX,
W,
H,
activeIndex.value
)
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
if (!target) return
openInlineEditor(target.index)
}
function pointerNormFromMouse(e: MouseEvent) {
const el = canvasEl.value
if (!el) return { x: 0, y: 0 }
const r = el.getBoundingClientRect()
return {
x: clampToCanvas((e.clientX - r.left) / r.width),
y: clampToCanvas((e.clientY - r.top) / r.height)
}
}
function openInlineEditor(index: number) {
const b = state.value.regions[index]
if (!b) return
activeIndex.value = index
const { w: W, h: H } = logicalSize()
const w = Math.min(W, Math.max(70, b.w * W))
const h = Math.min(H, Math.max(42, b.h * H))
const left = Math.max(0, Math.min(b.x * W, W - w))
const top = Math.max(0, Math.min(b.y * H, H - h))
inlineEditor.value = {
value: b.desc || '',
index,
style: {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`,
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
}
}
void nextTick(() => {
inlineEditorEl.value?.focus()
inlineEditorEl.value?.select()
})
}
function onInlineKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
inlineEditor.value = null
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
commitInlineEditor()
}
}
function commitInlineEditor() {
const ed = inlineEditor.value
if (!ed) return
const b = state.value.regions[ed.index]
if (b) b.desc = ed.value
inlineEditor.value = null
syncState()
}
function onCanvasKeyDown(e: KeyboardEvent) {
if (drawing.value) return
const idx = activeIndex.value
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
e.preventDefault()
e.stopPropagation()
removeRegion(idx)
syncState()
}
}
function removeRegion(i: number) {
state.value.regions.splice(i, 1)
if (!state.value.regions.length) activeIndex.value = -1
else if (i <= activeIndex.value)
activeIndex.value = Math.max(0, activeIndex.value - 1)
}
function setActiveType(t: 'obj' | 'text') {
if (activeRegion.value) {
activeRegion.value.type = t
syncState()
}
}
function clearAll() {
state.value.regions = []
activeIndex.value = -1
setLastIncoming([])
syncState()
}
function syncState() {
modelValue.value = toBoundingBoxes(
state.value.regions,
widthValue.value,
heightValue.value
)
requestDraw()
}
watch(containerWidth, () => requestDraw())
watch(
() => state.value.regions.length,
() => requestDraw()
)
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
if (!node) return
const snap = (v: number) =>
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
const targetW = snap(naturalWidth)
const targetH = snap(naturalHeight)
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && widthWidget.value !== targetW) {
widthWidget.value = targetW
widthWidget.callback?.(targetW)
}
if (heightWidget && heightWidget.value !== targetH) {
heightWidget.value = targetH
heightWidget.callback?.(targetH)
}
}
let lastBgUrl = ''
function updateBgImage() {
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('background')
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
const url = inputNode
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
: undefined
if (!url) {
if (bgImage.value) {
bgImage.value = null
lastBgUrl = ''
requestDraw()
}
return
}
if (url === lastBgUrl) return
lastBgUrl = url
const currentUrl = url
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
if (currentUrl !== lastBgUrl) return
bgImage.value = img
applyImageDimensions(img.naturalWidth, img.naturalHeight)
requestDraw()
}
img.src = url
}
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(() => {
if (rafHandle) cancelAnimationFrame(rafHandle)
})
return {
canvasStyle,
canvasCursor,
focused,
activeRegion,
hasRegions,
inlineEditor,
maxColors: MAX_ELEMENT_COLORS,
onPointerDown,
onCanvasPointerMove,
onDocPointerUp,
onPointerLeave,
onDoubleClick,
onCanvasKeyDown,
onInlineKeyDown,
commitInlineEditor,
setActiveType,
clearAll,
syncState,
grid
}
}

View File

@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
const scopes: EffectScope[] = []
afterEach(() => {
while (scopes.length) scopes.pop()?.stop()
})
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
return { modelValue, container, ...api }
}
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
addColor()
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
})
it('removes a color by index', () => {
const { modelValue, remove } = setup(['#a', '#b', '#c'])
remove(1)
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('updates the color at an index', () => {
const { modelValue, updateAt } = setup(['#a', '#b'])
updateAt(1, '#123456')
expect(modelValue.value).toEqual(['#a', '#123456'])
})
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', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
)
expect(modelValue.value).toEqual(['#b', '#a'])
})
it('cancels a stale drag when the primary button is no longer pressed', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
for (const i of [0, 1]) {
const swatch = document.createElement('div')
swatch.setAttribute('data-index', String(i))
container.value!.appendChild(swatch)
}
const second = container.value!.children[1] as HTMLDivElement
second.getBoundingClientRect = () =>
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
it('ignores non-left-button pointer downs', () => {
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
const swatch = document.createElement('div')
swatch.setAttribute('data-index', '1')
container.value!.appendChild(swatch)
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
document.dispatchEvent(
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
)
expect(modelValue.value).toEqual(['#a', '#b'])
})
})

View File

@@ -0,0 +1,99 @@
import { useEventListener } from '@vueuse/core'
import type { Ref, ShallowRef } from 'vue'
import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container
}: UsePaletteSwatchRowOptions) {
function updateAt(i: number, value: string) {
if (modelValue.value[i] === value) return
const next = modelValue.value.slice()
next[i] = value
modelValue.value = next
}
function remove(i: number) {
const next = modelValue.value.slice()
next.splice(i, 1)
modelValue.value = next
}
function addColor() {
modelValue.value = [...modelValue.value, '#ffffff']
}
const drag = ref<{
index: number
startX: number
startY: number
active: boolean
} | null>(null)
function onPointerDown(i: number, e: PointerEvent) {
if (e.button !== 0) return
drag.value = {
index: i,
startX: e.clientX,
startY: e.clientY,
active: false
}
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
const d = drag.value
if (!d) return
if ((e.buttons & 1) === 0) {
drag.value = null
return
}
if (!d.active) {
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
return
d.active = true
}
const rows =
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
if (!rows) return
for (const other of rows) {
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
const r = other.getBoundingClientRect()
if (
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top - 6 &&
e.clientY <= r.bottom + 6
) {
const oi = parseInt(other.dataset.index || '-1', 10)
if (oi < 0) continue
const next = modelValue.value.slice()
const [moved] = next.splice(d.index, 1)
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
modelValue.value = next
drag.value = null
return
}
}
})
useEventListener(document, 'pointerup', () => {
drag.value = null
})
useEventListener(document, 'pointercancel', () => {
drag.value = null
})
return {
updateAt,
remove,
addColor,
onPointerDown
}
}

View File

@@ -0,0 +1,443 @@
import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
import {
detectGamutFromChromaticities,
gamutToSrgbMatrix
} from '@/renderer/hdr/colorGamut'
import {
HDR_VIEWER_FRAGMENT_SHADER,
HDR_VIEWER_VERTEX_SHADER
} from '@/renderer/hdr/hdrViewerShader'
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
import {
computeChannelHistograms,
computeImageStats
} from '@/renderer/hdr/hdrStats'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
const MIN_ZOOM = 0.05
const MAX_ZOOM = 64
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
export const CHANNEL_MODES: ChannelMode[] = [
'rgb',
'r',
'g',
'b',
'a',
'luminance'
]
const CHANNEL_INDEX: Record<ChannelMode, number> = {
rgb: 0,
r: 1,
g: 2,
b: 3,
a: 4,
luminance: 5
}
interface PixelReadout {
x: number
y: number
r: number
g: number
b: number
a: number | null
}
interface ExrTexData {
header?: { chromaticities?: ChromaticityCoords }
}
function createLoader(url: string) {
const filename = getImageFilenameFromUrl(url)
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
const loader = new EXRLoader()
loader.setDataType(THREE.FloatType)
return loader
}
function makeReader(
data: ArrayLike<number>,
type: THREE.TextureDataType
): (index: number) => number {
if (type === THREE.HalfFloatType) {
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
}
return (index) => data[index]
}
function loadHdrTexture(
url: string
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
return new Promise((resolve, reject) => {
createLoader(url).load(
url,
(texture, texData) => {
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
resolve({
texture,
gamut: detectGamutFromChromaticities(chromaticities)
})
},
undefined,
reject
)
})
}
export function useHdrViewer() {
const exposureStops = ref(0)
const dither = ref(true)
const clipWarnings = ref(false)
const gamut = ref<GamutName>('sRGB')
const channel = ref<ChannelMode>('rgb')
const loading = ref(true)
const error = ref<string | null>(null)
const dimensions = ref<string | null>(null)
const stats = ref<ImageStats | null>(null)
const histograms = shallowRef<ChannelHistograms | null>(null)
const pixel = ref<PixelReadout | null>(null)
const histogram = computed<Uint32Array | null>(() => {
const channelHistograms = histograms.value
if (!channelHistograms) return null
switch (channel.value) {
case 'r':
return channelHistograms.r
case 'g':
return channelHistograms.g
case 'b':
return channelHistograms.b
case 'a':
return channelHistograms.a
default:
return channelHistograms.luminance
}
})
const containerRef = shallowRef<HTMLElement | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let viewport: WebGLViewport | null = null
let scene: THREE.Scene | null = null
let camera: THREE.OrthographicCamera | null = null
let material: THREE.ShaderMaterial | null = null
let mesh: THREE.Mesh | null = null
let texture: THREE.Texture | null = null
let imageAspect = 1
let frameRequested = false
let readSample: ((index: number) => number) | null = null
let imageWidth = 0
let imageHeight = 0
let imageChannels = 4
const raycaster = new THREE.Raycaster()
const pointerNdc = new THREE.Vector2()
function requestRender() {
if (!renderer || frameRequested) return
frameRequested = true
requestAnimationFrame(() => {
frameRequested = false
if (renderer && scene && camera) renderer.render(scene, camera)
})
}
function containerSize() {
const el = containerRef.value
return {
width: el?.clientWidth || 1,
height: el?.clientHeight || 1
}
}
function updateProjection() {
if (!camera) return
const { width, height } = containerSize()
const halfH = 0.5
const halfW = (0.5 * width) / height
camera.left = -halfW
camera.right = halfW
camera.top = halfH
camera.bottom = -halfH
camera.updateProjectionMatrix()
}
function fitView() {
if (!camera) return
const { width, height } = containerSize()
const containerAspect = width / height
camera.zoom = Math.min(1, containerAspect / imageAspect)
camera.position.set(0, 0, 1)
camera.updateProjectionMatrix()
requestRender()
}
function applyUniforms() {
if (!material) return
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
material.uniforms.uDither.value = dither.value
material.uniforms.uClipWarnings.value = clipWarnings.value
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
const m = gamutToSrgbMatrix(gamut.value)
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
m[0],
m[1],
m[2],
m[3],
m[4],
m[5],
m[6],
m[7],
m[8]
)
requestRender()
}
function buildScene() {
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
viewport = new WebGLViewport(renderer)
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0x0a0a0a, 1)
scene = new THREE.Scene()
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
camera.position.set(0, 0, 1)
material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: HDR_VIEWER_VERTEX_SHADER,
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
uniforms: {
uImage: { value: null },
uGamutToSRGB: { value: new THREE.Matrix3() },
uGain: { value: 1 },
uChannel: { value: 0 },
uDither: { value: true },
uClipWarnings: { value: false },
uClipRange: { value: new THREE.Vector2(0, 1) }
}
})
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
scene.add(mesh)
}
function resize() {
if (!renderer) return
const { width, height } = containerSize()
renderer.setSize(width, height, false)
updateProjection()
requestRender()
}
function setTexture(loaded: THREE.DataTexture) {
if (!material || !mesh) return
loaded.colorSpace = THREE.LinearSRGBColorSpace
loaded.minFilter = THREE.LinearFilter
loaded.magFilter = THREE.LinearFilter
loaded.needsUpdate = true
const { width, height, data } = loaded.image
texture = loaded
imageAspect = width / height
mesh.scale.set(imageAspect, 1, 1)
material.uniforms.uImage.value = loaded
dimensions.value = `${width} x ${height}`
if (!data) return
imageWidth = width
imageHeight = height
imageChannels = data.length / (width * height)
readSample = makeReader(data, loaded.type)
stats.value = computeImageStats(readSample, data.length, imageChannels)
histograms.value = computeChannelHistograms(
readSample,
data.length,
imageChannels
)
}
async function mount(container: HTMLElement, url: string) {
containerRef.value = container
loading.value = true
error.value = null
try {
buildScene()
container.appendChild(renderer!.domElement)
renderer!.domElement.classList.add('block', 'size-full')
resize()
applyUniforms()
attachInteractions(renderer!.domElement)
viewport!.observeResize(container, resize)
const { texture: loaded, gamut: detectedGamut } =
await loadHdrTexture(url)
if (!material || !mesh) {
loaded.dispose()
return
}
gamut.value = detectedGamut
setTexture(loaded)
applyUniforms()
fitView()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
dispose()
} finally {
loading.value = false
}
}
function normalizeExposure() {
const max = stats.value?.max ?? 0
exposureStops.value = max > 0 ? -Math.log2(max) : 0
}
function attachInteractions(canvas: HTMLCanvasElement) {
canvas.addEventListener('wheel', onWheel, { passive: false })
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onHoverMove)
canvas.addEventListener('pointerleave', onHoverLeave)
}
function onWheel(event: WheelEvent) {
if (!camera) return
event.preventDefault()
const factor = Math.exp(-event.deltaY * 0.001)
const nextZoom = THREE.MathUtils.clamp(
camera.zoom * factor,
MIN_ZOOM,
MAX_ZOOM
)
camera.zoom = nextZoom
camera.updateProjectionMatrix()
requestRender()
}
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
null
function onPointerDown(event: PointerEvent) {
if (!camera) return
dragStart = {
x: event.clientX,
y: event.clientY,
camX: camera.position.x,
camY: camera.position.y
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!camera || !dragStart) return
const { height } = containerSize()
const worldPerPixel = 1 / (height * camera.zoom)
camera.position.x =
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
camera.position.y =
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
requestRender()
}
function onPointerUp() {
dragStart = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
function onHoverMove(event: PointerEvent) {
if (!camera || !mesh || !renderer || dragStart || !readSample) return
const rect = renderer.domElement.getBoundingClientRect()
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
raycaster.setFromCamera(pointerNdc, camera)
const hit = raycaster.intersectObject(mesh)[0]
if (!hit?.uv) {
pixel.value = null
return
}
const col = THREE.MathUtils.clamp(
Math.floor(hit.uv.x * imageWidth),
0,
imageWidth - 1
)
const row = THREE.MathUtils.clamp(
Math.floor(hit.uv.y * imageHeight),
0,
imageHeight - 1
)
const base = (row * imageWidth + col) * imageChannels
pixel.value = {
x: col,
y: imageHeight - 1 - row,
r: readSample(base),
g: readSample(base + 1),
b: readSample(base + 2),
a: imageChannels === 4 ? readSample(base + 3) : null
}
}
function onHoverLeave() {
pixel.value = null
}
function dispose() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
if (renderer) {
renderer.domElement.removeEventListener('wheel', onWheel)
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
renderer.domElement.removeEventListener('pointermove', onHoverMove)
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
}
viewport?.disposeRenderer()
texture?.dispose()
material?.dispose()
mesh?.geometry.dispose()
renderer = null
viewport = null
scene = null
camera = null
material = null
mesh = null
texture = null
readSample = null
}
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
onUnmounted(dispose)
return {
exposureStops,
dither,
clipWarnings,
gamut,
channel,
loading,
error,
dimensions,
stats,
histogram,
pixel,
mount,
dispose,
fitView,
normalizeExposure
}
}

View File

@@ -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 () => {

View File

@@ -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(

View File

@@ -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 =

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTextFileContent } from '@/composables/useTextFileContent'
function stubFetch(response: Partial<Response> | Error) {
const mock =
response instanceof Error
? vi.fn().mockRejectedValue(response)
: vi.fn().mockResolvedValue(response)
vi.stubGlobal('fetch', mock)
return mock
}
describe(useTextFileContent, () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns inline content without fetching', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent } = useTextFileContent(() => ({
content: 'inline text',
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('inline text'))
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches text from the url when no inline content is present', async () => {
const fetchMock = stubFetch({
ok: true,
text: () => Promise.resolve('fetched text')
})
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(textContent.value).toBe('fetched text'))
expect(fetchMock).toHaveBeenCalledWith('http://example.com/file.txt')
expect(hasError.value).toBe(false)
})
it('flags an error for a non-ok response', async () => {
stubFetch({ ok: false })
const { textContent, hasError } = useTextFileContent(() => ({
url: 'http://example.com/missing.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
expect(textContent.value).toBe('')
})
it('flags an error when the fetch rejects', async () => {
stubFetch(new Error('network down'))
const { hasError } = useTextFileContent(() => ({
url: 'http://example.com/file.txt'
}))
await vi.waitFor(() => expect(hasError.value).toBe(true))
})
it('resolves empty content when there is no source', async () => {
const fetchMock = stubFetch(new Error('should not be called'))
const { textContent, isLoading } = useTextFileContent(() => undefined)
await vi.waitFor(() => expect(isLoading.value).toBe(false))
expect(textContent.value).toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,40 @@
import { computedAsync } from '@vueuse/core'
import { ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface TextSource {
content?: string
url?: string
}
export function useTextFileContent(
source: MaybeRefOrGetter<TextSource | undefined>
) {
const isLoading = ref(false)
const hasError = ref(false)
const textContent = computedAsync(
async () => {
hasError.value = false
const { content, url } = toValue(source) ?? {}
if (content !== undefined) return content
if (!url) return ''
const response = await fetch(url)
if (!response.ok) {
hasError.value = true
return ''
}
return await response.text()
},
'',
{
evaluating: isLoading,
onError: () => {
hasError.value = true
}
}
)
return { textContent, isLoading, hasError }
}

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { state } = vi.hoisted(() => ({
state: {
extension: null as { nodeCreated: (node: unknown) => void } | null,
widgetState: undefined as { options: Record<string, unknown> } | undefined
}
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension: (ext: { nodeCreated: (node: unknown) => void }) => {
state.extension = ext
}
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => state.widgetState })
}))
await import('./createBoundingBoxes')
interface MockWidget {
name: string
hidden: boolean
options: Record<string, unknown>
}
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: 'last_incoming', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
id: 1,
graph: { rootGraph: { id: 'test-graph' } },
size: [100, 100] as [number, number],
setSize: vi.fn(),
findInputSlot: () => 0,
isInputConnected: () => connected,
widgets,
onConnectionsChange: undefined as unknown
}
}
beforeEach(() => {
state.widgetState = undefined
})
describe('Comfy.CreateBoundingBoxes extension', () => {
it('ignores nodes of other classes', () => {
const node = makeNode(true, 'SomethingElse')
state.extension!.nodeCreated(node)
expect(node.setSize).not.toHaveBeenCalled()
})
it('enlarges the node and hides width/height when a background is connected', () => {
const node = makeNode(true)
state.extension!.nodeCreated(node)
expect(node.setSize).toHaveBeenCalledWith([420, 560])
expect(node.widgets[0].hidden).toBe(true)
expect(node.widgets[1].hidden).toBe(true)
expect(node.widgets[0].options.hidden).toBe(true)
expect(node.widgets[2].hidden).toBe(false)
})
it('shows width/height when no background is connected', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(node.widgets[0].hidden).toBe(false)
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)
state.extension!.nodeCreated(node)
expect(state.widgetState.options.hidden).toBe(true)
})
it('chains a connections-change handler that re-syncs visibility', () => {
const node = makeNode(false)
state.extension!.nodeCreated(node)
expect(typeof node.onConnectionsChange).toBe('function')
})
})

View File

@@ -0,0 +1,51 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
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',
nodeCreated(node) {
if (node.constructor.comfyClass !== 'CreateBoundingBoxes') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 420), Math.max(oldHeight, 560)])
const widgetValueStore = useWidgetValueStore()
const setWidgetHidden = (
widget: NonNullable<typeof node.widgets>[number],
hidden: boolean
) => {
widget.hidden = hidden
const graphId = resolveNodeRootGraphId(node)
const state = graphId
? widgetValueStore.getWidget(graphId, node.id, widget.name)
: 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)) setWidgetHidden(widget, hidden)
}
}
for (const widget of node.widgets ?? []) {
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
syncDimensionVisibility
)
}
})

View File

@@ -2,6 +2,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './createBoundingBoxes'
import './customWidgets'
import './dynamicPrompts'
import './editAttention'
@@ -21,6 +22,7 @@ if (!isCloud) {
import './noteNode'
import './painter'
import './previewAny'
import './saveText'
import './rerouteNode'
import './saveImageExtraOutput'
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)

View File

@@ -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)

View File

@@ -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 = {
@@ -284,8 +287,11 @@ useExtensionService().registerExtension({
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'Save3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'SaveGaussianSplat',
'SavePointCloud'
])
return {
LOAD_3D(node) {
@@ -340,7 +346,7 @@ useExtensionService().registerExtension({
name: inputName,
component: Load3D,
inputSpec: { ...inputSpecLoad3D, name: inputName },
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3D'
@@ -563,7 +569,7 @@ useExtensionService().registerExtension({
name: inputSpecPreview3D.name,
component: Load3D,
inputSpec: inputSpecPreview3D,
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3D'
@@ -676,155 +682,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'
)
)

View File

@@ -14,6 +14,7 @@ vi.mock('three/examples/jsm/controls/OrbitControls', () => {
object: THREE.Camera
domElement: HTMLElement
enableDamping = false
enabled = true
target = new THREE.Vector3()
update = vi.fn()
dispose = vi.fn()
@@ -165,4 +166,24 @@ describe('ControlsManager', () => {
expect(manager.controls.dispose).toHaveBeenCalled()
})
})
describe('detach / attach', () => {
it('detach disables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
expect(manager.controls.enabled).toBe(true)
manager.detach()
expect(manager.controls.enabled).toBe(false)
})
it('attach re-enables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager.detach()
manager.attach()
expect(manager.controls.enabled).toBe(true)
})
})
})

View File

@@ -63,6 +63,15 @@ export class ControlsManager implements ControlsManagerInterface {
camera.position.copy(position)
this.controls.update()
}
detach(): void {
this.controls.enabled = false
}
attach(): void {
this.controls.enabled = true
}
reset(): void {
this.controls.target.set(0, 0, 0)
this.controls.update()

View File

@@ -2,26 +2,19 @@ import * as THREE from 'three'
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'
import type { AnimationManager } from './AnimationManager'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { DIRECT_EXPORT_FORMATS } from './constants'
import { ModelExporter } from './ModelExporter'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
import type { RecordingManager } from './RecordingManager'
import type { SceneManager } from './SceneManager'
import type { SceneModelManager } from './SceneModelManager'
import type { ViewHelperManager } from './ViewHelperManager'
import { Viewport3d, type Viewport3dDeps } from './Viewport3d'
import { computeCameraFromMatrices } from './cameraFromMatrices'
import { DIRECT_EXPORT_FORMATS } from './constants'
import type {
CameraState,
CaptureResult,
EventCallback,
GizmoMode,
Load3DOptions,
LoadModelOptions,
@@ -29,20 +22,10 @@ import type {
Model3DTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Load3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
export type Load3dDeps = Viewport3dDeps & {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -70,22 +53,8 @@ function positionThumbnailCamera(
camera.updateProjectionMatrix()
}
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
class Load3d extends Viewport3d {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -93,19 +62,8 @@ class Load3d {
gizmoManager: GizmoManager
adapterRef: AdapterRef
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private hasLoadedModel: boolean = false
constructor(
@@ -113,26 +71,9 @@ class Load3d {
deps: Load3dDeps,
options: Load3DOptions = {}
) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
super(container, deps, options)
if (options.width && options.height) {
this.targetWidth = options.width
this.targetHeight = options.height
this.targetAspectRatio = options.width / options.height
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.hdriManager = deps.hdriManager
this.viewHelperManager = deps.viewHelperManager
this.loaderManager = deps.loaderManager
this.modelManager = deps.modelManager
this.recordingManager = deps.recordingManager
@@ -140,35 +81,16 @@ class Load3d {
this.gizmoManager = deps.gizmoManager
this.adapterRef = deps.adapterRef
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
this.start()
}
private async repaintWhenSparkPaintable(): Promise<void> {
@@ -178,49 +100,14 @@ class Load3d {
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
return this.eventManager
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getLoaderManager(): LoaderManager {
return this.loaderManager
}
getModelManager(): SceneModelManager {
return this.modelManager
}
getRecordingManager(): RecordingManager {
return this.recordingManager
}
@@ -229,119 +116,12 @@ class Load3d {
return this.gizmoManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
private shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
forceRender(): void {
const delta = this.clock.getDelta()
protected override tickPerFrame(delta: number): void {
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
super.tickPerFrame(delta)
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
private startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
this.STATUS_MOUSE_ON_NODE = onNode
}
updateStatusMouseOnScene(onScene: boolean): void {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
override isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
@@ -444,9 +224,27 @@ class Load3d {
return ModelExporter.detectFormatFromURL(url)
}
protected override onActiveCameraChanged(): void {
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
@@ -473,7 +271,6 @@ class Load3d {
height
)
} else {
// No aspect ratio constraints: fill container
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
@@ -488,12 +285,6 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
@@ -502,39 +293,6 @@ class Load3d {
this.forceRender()
}
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
this.viewHelperManager.recreateViewHelper()
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.cameraManager.getCurrentCameraType()
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
this.forceRender()
}
getCameraState(): CameraState {
return this.cameraManager.getCameraState()
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setCameraFromMatrices(
extrinsics: readonly (readonly number[])[],
intrinsics: readonly (readonly number[])[]
@@ -553,19 +311,15 @@ class Load3d {
this.setFOV(fovYDegrees)
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)
this.forceRender()
}
/**
* Monotonic counter that ticks once per loadModel call, **before** any
* await. Callers can capture this immediately after triggering a load and
* later compare against `currentLoadGeneration` to verify their load is
* still the latest one — useful when chaining post-load work
* (e.g. applying camera matrices) through `whenLoadIdle()`, which would
* otherwise wait for any newer queued load and apply stale state to it.
*/
get currentLoadGeneration(): number {
return this._loadGeneration
}
@@ -606,8 +360,6 @@ class Load3d {
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
// First load always uses default framing; subsequent reloads preserve
// the user's framing.
const shouldRetainView = this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
@@ -623,7 +375,6 @@ class Load3d {
await this.loaderManager.loadModel(url, originalFileName, options)
// Auto-detect and setup animations if present
if (this.modelManager.currentModel) {
this.animationManager.setupModelAnimations(
this.modelManager.currentModel,
@@ -633,7 +384,6 @@ class Load3d {
}
if (savedCameraState) {
// setupForModel runs during loadModel and clobbers the camera; restore on top.
if (
savedCameraState.cameraType !==
this.cameraManager.getCurrentCameraType()
@@ -674,11 +424,6 @@ class Load3d {
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
async loadHDRI(url: string): Promise<void> {
await this.hdriManager.loadHDRI(url)
this.forceRender()
@@ -706,73 +451,10 @@ class Load3d {
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.addEventListener(event, callback)
}
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.removeEventListener(event, callback)
}
emitModelReady(): void {
this.eventManager.emitEvent('modelReady', null)
}
refreshViewport(): void {
this.handleResize()
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
// Scale pixel density to match the graph zoom level so the 3D scene
// renders at the correct resolution when the canvas is zoomed in or out.
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
// No aspect ratio constraint: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.forceRender()
}
captureScene(width: number, height: number): Promise<CaptureResult> {
this.gizmoManager.removeFromScene()
@@ -818,7 +500,6 @@ class Load3d {
this.recordingManager.clearRecording()
}
// Animation methods
public setAnimationSpeed(speed: number): void {
this.animationManager.setAnimationSpeed(speed)
}
@@ -977,41 +658,15 @@ class Load3d {
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
protected override disposeManagers(): void {
super.disposeManagers()
this.hdriManager.dispose()
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()
this.adapterRef.current = null
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()
}
}

View File

@@ -0,0 +1,433 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
type CameraStub = {
toggleCamera: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
setFOV: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
type SceneStub = {
captureScene: ReturnType<typeof vi.fn>
setBackgroundColor: ReturnType<typeof vi.fn>
setBackgroundImage: ReturnType<typeof vi.fn>
removeBackgroundImage: ReturnType<typeof vi.fn>
toggleGrid: ReturnType<typeof vi.fn>
setBackgroundRenderMode: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
renderBackground: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
updateBackgroundSize: ReturnType<typeof vi.fn>
backgroundTexture: unknown
backgroundMesh: unknown
scene: THREE.Scene
}
function makeViewportInstance() {
const cameraManager: CameraStub = {
toggleCamera: vi.fn(),
setupForModel: vi.fn(),
reset: vi.fn(),
getCameraState: vi.fn(() => ({
position: new THREE.Vector3(),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'perspective' as const
})),
setCameraState: vi.fn(),
setFOV: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
captureScene: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
removeBackgroundImage: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundRenderMode: vi.fn(),
handleResize: vi.fn(),
renderBackground: vi.fn(),
dispose: vi.fn(),
updateBackgroundSize: vi.fn(),
backgroundTexture: null,
backgroundMesh: null,
scene: new THREE.Scene()
}
const controlsManager = {
updateCamera: vi.fn(),
update: vi.fn(),
dispose: vi.fn(),
reset: vi.fn(),
detach: vi.fn(),
attach: vi.fn()
}
const lightingManager = {
setLightIntensity: vi.fn(),
dispose: vi.fn()
}
const viewHelperManager = {
recreateViewHelper: vi.fn(),
update: vi.fn(),
visibleViewHelper: vi.fn(),
viewHelper: { render: vi.fn() },
dispose: vi.fn()
}
const eventManager = {
emitEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const viewport = Object.create(Viewport3d.prototype) as Viewport3d
Object.assign(viewport, {
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: vi.fn(),
handleResize: vi.fn()
})
return {
viewport,
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: viewport.forceRender as ReturnType<typeof vi.fn>
}
}
describe('Viewport3d', () => {
let ctx: ReturnType<typeof makeViewportInstance>
beforeEach(() => {
ctx = makeViewportInstance()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('camera delegation (model-independent)', () => {
it('toggleCamera updates controls and recreates view helper without touching model state', () => {
ctx.viewport.toggleCamera('orthographic')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
})
})
describe('isActive (no model concerns)', () => {
it('returns false when no mouse activity is present', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(ctx.viewport.isActive()).toBe(false)
})
it('does not consult recording or animation state — that is a Load3d concern', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(() => ctx.viewport.isActive()).not.toThrow()
})
})
describe('manager accessors', () => {
it('exposes managers through both fields and getters', () => {
expect(ctx.viewport.cameraManager).toBe(ctx.cameraManager)
expect(ctx.viewport.getCameraManager()).toBe(ctx.cameraManager)
expect(ctx.viewport.sceneManager).toBe(ctx.sceneManager)
expect(ctx.viewport.getSceneManager()).toBe(ctx.sceneManager)
expect(ctx.viewport.controlsManager).toBe(ctx.controlsManager)
expect(ctx.viewport.getControlsManager()).toBe(ctx.controlsManager)
expect(ctx.viewport.lightingManager).toBe(ctx.lightingManager)
expect(ctx.viewport.getLightingManager()).toBe(ctx.lightingManager)
expect(ctx.viewport.viewHelperManager).toBe(ctx.viewHelperManager)
expect(ctx.viewport.getViewHelperManager()).toBe(ctx.viewHelperManager)
expect(ctx.viewport.eventManager).toBe(ctx.eventManager)
expect(ctx.viewport.getEventManager()).toBe(ctx.eventManager)
})
})
describe('POV swap (setExternalActiveCamera)', () => {
it('getRenderCamera returns the orbit camera when no external camera is set', () => {
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('getRenderCamera returns the external camera once installed', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.viewport.getRenderCamera()).toBe(subjectCamera)
})
it('installing an external camera detaches controls and hides the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
false
)
expect(ctx.forceRender).toHaveBeenCalled()
})
it('clearing the external camera re-attaches controls and shows the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.controlsManager.attach.mockClear()
ctx.viewHelperManager.visibleViewHelper.mockClear()
ctx.viewport.setExternalActiveCamera(null)
expect(ctx.controlsManager.attach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(true)
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('setting the same external camera twice is a no-op', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).not.toHaveBeenCalled()
})
})
describe('overlay', () => {
function makeOverlay() {
return {
attach: vi.fn(),
detach: vi.fn(),
update: vi.fn(),
onActiveCameraChange: vi.fn(),
dispose: vi.fn()
}
}
it('setOverlay attaches to the scene and notifies of the current render camera', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
expect(overlay.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewport.getOverlay()).toBe(overlay)
})
it('replacing an overlay detaches and disposes the prior one', () => {
const first = makeOverlay()
const second = makeOverlay()
ctx.viewport.setOverlay(first)
ctx.viewport.setOverlay(second)
expect(first.detach).toHaveBeenCalledOnce()
expect(first.dispose).toHaveBeenCalledOnce()
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(ctx.viewport.getOverlay()).toBe(second)
})
it('removeOverlay detaches and disposes the installed overlay', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
ctx.viewport.removeOverlay()
expect(overlay.detach).toHaveBeenCalledOnce()
expect(overlay.dispose).toHaveBeenCalledOnce()
expect(ctx.viewport.getOverlay()).toBeNull()
})
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
const tick = (
ctx.viewport as unknown as {
tickPerFrame(delta: number): void
}
).tickPerFrame.bind(ctx.viewport)
tick(0.016)
expect(overlay.update).toHaveBeenCalledWith(0.016)
expect(ctx.viewHelperManager.update).toHaveBeenCalledWith(0.016)
expect(ctx.controlsManager.update).toHaveBeenCalledOnce()
})
it('toggleCamera notifies the overlay with the new orbit camera (when no POV)', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
})
it('toggleCamera does NOT notify the overlay while a POV camera is active', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
ctx.viewport.setExternalActiveCamera(subjectCamera)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).not.toHaveBeenCalled()
})
it('setExternalActiveCamera notifies the overlay with the new render camera', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(subjectCamera)
})
})
describe('applyTargetSize guards', () => {
function applyTargetSize(width: number, height: number): void {
;(
ctx.viewport as unknown as {
applyTargetSize(w: number, h: number): void
}
).applyTargetSize(width, height)
}
beforeEach(() => {
Object.assign(ctx.viewport, {
targetWidth: 0,
targetHeight: 0,
targetAspectRatio: 1
})
})
it('writes width / height / aspect when both inputs are positive finite', () => {
applyTargetSize(800, 400)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it.for([
['zero width', 0, 100],
['zero height', 100, 0],
['negative width', -100, 100],
['negative height', 100, -100],
['NaN width', Number.NaN, 100],
['Infinity height', 100, Number.POSITIVE_INFINITY]
] as const)('rejects %s without touching prior state', ([, w, h]) => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
applyTargetSize(w, h)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it('setTargetSize routes through the guard', () => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
ctx.viewport.setTargetSize(0, 0)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
Object.assign(ctx.viewport, {
hasStarted: false,
initialRenderTimer: null,
startAnimation: vi.fn(),
renderLoop: { stop: vi.fn() },
resizeObserver: null,
disposeContextMenuGuard: null,
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: Object.assign(document.createElement('canvas'), {
remove: vi.fn()
})
},
disposeManagers: vi.fn()
})
})
afterEach(() => {
vi.useRealTimers()
})
it('start schedules a deferred forceRender and remove clears it before the timer fires', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
ctx.viewport.remove()
vi.advanceTimersByTime(500)
expect(ctx.forceRender).not.toHaveBeenCalled()
})
it('the deferred forceRender does fire when remove is not called', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
vi.advanceTimersByTime(100)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,416 @@
import * as THREE from 'three'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { LightingManager } from './LightingManager'
import type { SceneManager } from './SceneManager'
import type { ViewHelperManager } from './ViewHelperManager'
import type {
CameraState,
EventCallback,
Load3DOptions,
SceneOverlay
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Viewport3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
}
export class Viewport3d extends WebGLViewport {
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private getZoomScaleCallback: (() => number) | undefined
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
constructor(
container: Element | HTMLElement,
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
super(deps.renderer)
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
if (options.width !== undefined && options.height !== undefined) {
this.applyTargetSize(options.width, options.height)
}
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.viewHelperManager = deps.viewHelperManager
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.observeResize(container, () => this.handleResize())
}
start(): void {
if (this.hasStarted) return
this.hasStarted = true
this.handleResize()
this.startAnimation()
this.initialRenderTimer = setTimeout(() => {
this.initialRenderTimer = null
this.forceRender()
}, 100)
}
private hasStarted: boolean = false
private applyTargetSize(width: number, height: number): void {
if (!Number.isFinite(width) || !Number.isFinite(height)) return
if (width <= 0 || height <= 0) return
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
return this.eventManager
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
protected shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
forceRender(): void {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
}
protected tickPerFrame(delta: number): void {
this.overlay?.update?.(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
}
getRenderCamera(): THREE.Camera {
return this.externalActiveCamera ?? this.cameraManager.activeCamera
}
setExternalActiveCamera(camera: THREE.Camera | null): void {
if (this.externalActiveCamera === camera) return
this.externalActiveCamera = camera
if (camera) {
this.controlsManager.detach()
this.viewHelperManager.visibleViewHelper(false)
} else {
this.controlsManager.attach()
this.viewHelperManager.visibleViewHelper(true)
}
this.overlay?.onActiveCameraChange?.(this.getRenderCamera())
this.forceRender()
}
setOverlay(overlay: SceneOverlay): void {
if (this.overlay === overlay) return
if (this.overlay) {
this.overlay.detach()
this.overlay.dispose()
}
this.overlay = overlay
overlay.attach(this.sceneManager.scene)
overlay.onActiveCameraChange?.(this.getRenderCamera())
this.forceRender()
}
removeOverlay(): void {
if (!this.overlay) return
this.overlay.detach()
this.overlay.dispose()
this.overlay = null
this.forceRender()
}
getOverlay(): SceneOverlay | null {
return this.overlay
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.applyTargetSize(dims.width, dims.height)
}
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
protected startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
this.STATUS_MOUSE_ON_NODE = onNode
}
updateStatusMouseOnScene(onScene: boolean): void {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
recording: false,
initialRenderDone: this.INITIAL_RENDER_DONE,
animationPlaying: false
})
}
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.onActiveCameraChanged()
this.viewHelperManager.recreateViewHelper()
if (!this.externalActiveCamera) {
this.overlay?.onActiveCameraChange?.(this.cameraManager.activeCamera)
}
this.handleResize()
}
protected onActiveCameraChanged(): void {}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.cameraManager.getCurrentCameraType()
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
this.forceRender()
}
getCameraState(): CameraState {
return this.cameraManager.getCameraState()
}
setTargetSize(width: number, height: number): void {
this.applyTargetSize(width, height)
this.handleResize()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.addEventListener(event, callback)
}
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.removeEventListener(event, callback)
}
refreshViewport(): void {
this.handleResize()
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.applyTargetSize(dims.width, dims.height)
}
}
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.forceRender()
}
remove(): void {
if (this.initialRenderTimer) {
clearTimeout(this.initialRenderTimer)
this.initialRenderTimer = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderLoop?.stop()
this.renderLoop = null
this.disposeManagers()
this.disposeRenderer()
}
protected disposeManagers(): void {
if (this.overlay) {
this.overlay.detach()
this.overlay.dispose()
this.overlay = null
}
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
this.viewHelperManager.dispose()
}
}

View File

@@ -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
@@ -244,6 +245,14 @@ export interface LoadModelOptions {
silentOnNotFound?: boolean
}
export interface SceneOverlay {
attach(scene: THREE.Scene): void
detach(): void
update?(deltaSeconds: number): void
onActiveCameraChange?(camera: THREE.Camera): void
dispose(): void
}
export interface LoaderManagerInterface {
init(): void
dispose(): void

View File

@@ -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)

View File

@@ -45,7 +45,7 @@ useExtensionService().registerExtension({
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3DAdvanced'

View File

@@ -89,7 +89,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) => {

View File

@@ -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()

View File

@@ -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')
)

View File

@@ -1,6 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyExtension } from '@/types/comfy'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const { addTextPreviewWidgets, updateTextPreviewWidgets } = vi.hoisted(() => ({
addTextPreviewWidgets: vi.fn(),
updateTextPreviewWidgets: vi.fn()
}))
vi.mock('@/extensions/core/textPreviewWidgets', () => ({
addTextPreviewWidgets,
updateTextPreviewWidgets
}))
const capturedExtensions: ComfyExtension[] = []
@@ -12,103 +23,51 @@ vi.mock('@/services/extensionService', () => ({
})
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
type BeforeRegister = NonNullable<ComfyExtension['beforeRegisterNodeDef']>
interface MockWidget {
name: string
options: Record<string, unknown>
element: { readOnly: boolean }
callback?: (value: unknown) => void
value: unknown
hidden: boolean
label: string
serialize?: boolean
}
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
const createdWidgets: MockWidget[] = []
const nodeType = { prototype: {} } as unknown as Parameters<BeforeRegister>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<BeforeRegister>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<BeforeRegister>[2]
)
vi.mock('@/scripts/widgets', () => {
const create =
(kind: string) =>
(
node: { widgets?: MockWidget[] },
name: string,
_info: unknown,
_app: unknown
) => {
const widget: MockWidget = {
name,
options: {},
element: { readOnly: false },
value: kind === 'BOOLEAN' ? false : '',
hidden: false,
label: ''
}
node.widgets = node.widgets ?? []
node.widgets.push(widget)
createdWidgets.push(widget)
return { widget }
}
return {
ComfyWidgets: {
MARKDOWN: create('MARKDOWN'),
STRING: create('STRING'),
BOOLEAN: create('BOOLEAN')
}
const node = {} as LGraphNode
const proto = nodeType.prototype as {
onNodeCreated?: () => void
onExecuted?: (message: { text?: string }) => void
}
})
return { node, proto }
}
describe('PreviewAny extension', () => {
beforeEach(async () => {
capturedExtensions.length = 0
createdWidgets.length = 0
addTextPreviewWidgets.mockClear()
updateTextPreviewWidgets.mockClear()
vi.resetModules()
await import('./previewAny')
})
async function setupNode() {
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
expect(ext).toBeDefined()
it('adds the shared text preview widgets on node creation', async () => {
const { node, proto } = await setupNode()
const nodeType = { prototype: {} } as unknown as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[0]
const nodeData = { name: 'PreviewAny' } as Parameters<
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
>[1]
await ext!.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
)
const node: { widgets?: MockWidget[] } = {}
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return node
}
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
await setupNode()
expect(addTextPreviewWidgets).toHaveBeenCalledWith(node)
})
const previewMarkdown = createdWidgets.find(
(w) => w.name === 'preview_markdown'
)
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
it('updates the preview with executed text', async () => {
const { node, proto } = await setupNode()
const message = { text: 'hello' }
expect(previewMarkdown).toBeDefined()
expect(previewText).toBeDefined()
expect(previewMode).toBeDefined()
proto.onExecuted!.call(node, message)
// widget.options.serialize === false is what executionUtil.graphToPrompt
// checks to exclude a widget from the API prompt sent to the backend.
// Without this, post-execution widget value updates (the rendered preview
// text) get serialized as inputs, change the cache signature, and cause
// the node to re-execute on the next prompt.
expect(previewMarkdown!.options.serialize).toBe(false)
expect(previewText!.options.serialize).toBe(false)
expect(previewMode!.options.serialize).toBe(false)
expect(updateTextPreviewWidgets).toHaveBeenCalledWith(node, message)
})
})

View File

@@ -4,10 +4,11 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
*/
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { type DOMWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
@@ -16,82 +17,18 @@ useExtensionService().registerExtension({
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) {
if (nodeData.name === 'PreviewAny') {
const onNodeCreated = nodeType.prototype.onNodeCreated
if (nodeData.name !== 'PreviewAny') return
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const showValueWidget = ComfyWidgets['MARKDOWN'](
this,
'preview_markdown',
['MARKDOWN', {}],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showValueWidgetPlain = ComfyWidgets['STRING'](
this,
'preview_text',
['STRING', { multiline: true }],
app
).widget as DOMWidget<HTMLTextAreaElement, string>
const showAsPlaintextWidget = ComfyWidgets['BOOLEAN'](
this,
'previewMode',
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plaintext', default: false }
],
app
)
showAsPlaintextWidget.widget.callback = (value: boolean) => {
showValueWidget.hidden = !value
showValueWidget.options.hidden = !value
showValueWidgetPlain.hidden = value
showValueWidgetPlain.options.hidden = value
}
showValueWidget.label = 'Preview'
showValueWidget.hidden = true
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.options.serialize = false
showValueWidget.element.readOnly = true
showValueWidget.serialize = false
showValueWidgetPlain.label = 'Preview'
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.options.serialize = false
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.serialize = false
// The previewMode toggle is a frontend-only display preference and
// is not declared in the backend INPUT_TYPES, so it must not be
// serialized into the API prompt (would alter the cache signature).
showAsPlaintextWidget.widget.options.serialize = false
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted === null || onExecuted === void 0
? void 0
: onExecuted.apply(this, [message])
const previewWidgets =
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
for (const previewWidget of previewWidgets) {
const text = message.text ?? ''
previewWidget.value = Array.isArray(text)
? (text?.join('\n\n') ?? '')
: text
}
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
}
})

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
const { app } = vi.hoisted(() => ({
app: {
registerExtension: vi.fn(),
graph: undefined as unknown as LGraph
}
}))
vi.mock('@/scripts/app', () => ({ app }))
type BeforeRegisterNodeDef = NonNullable<
ComfyExtension['beforeRegisterNodeDef']
>
interface FilenamePrefixWidget {
name: string
value: unknown
serializeValue?: () => string
}
async function loadExtension(): Promise<ComfyExtension> {
vi.resetModules()
app.registerExtension.mockClear()
await import('./saveImageExtraOutput')
return app.registerExtension.mock.calls[0][0] as ComfyExtension
}
async function createNodeWithFilenamePrefix(
nodeName: string,
prefix: string
): Promise<FilenamePrefixWidget> {
const ext = await loadExtension()
const nodeType = {
prototype: {}
} as unknown as Parameters<BeforeRegisterNodeDef>[0]
const nodeData = { name: nodeName } as ComfyNodeDef
await ext.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<BeforeRegisterNodeDef>[2]
)
const widget: FilenamePrefixWidget = {
name: 'filename_prefix',
value: prefix
}
const node = { widgets: [widget] }
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return widget
}
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },
widgets: [{ name: 'seed', value: 12345 }]
} as unknown as LGraphNode)
app.graph = graph
})
it.each([
'SaveImage',
'SaveImageAdvanced',
'SaveSVGNode',
'SaveVideo',
'SaveAnimatedWEBP',
'SaveWEBM',
'SaveAudio',
'SaveAudioMP3',
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',
'ModelSave',
'LoraSave',
'SaveLatent'
])(
'resolves text replacements in the filename_prefix of %s on serialize',
async (nodeName) => {
const widget = await createNodeWithFilenamePrefix(
nodeName,
'ComfyUI_%Sampler.seed%'
)
expect(widget.serializeValue!()).toBe('ComfyUI_12345')
}
)
})

View File

@@ -6,11 +6,19 @@ import { app } from '../../scripts/app'
const saveNodeTypes = new Set([
'SaveImage',
'SaveImageAdvanced',
'SaveSVGNode',
'SaveVideo',
'SaveAnimatedWEBP',
'SaveWEBM',
'SaveAudio',
'SaveAudioMP3',
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -107,7 +107,7 @@ useExtensionService().registerExtension({
name: inputSpec.name,
component: Load3D,
inputSpec,
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3D'

View File

@@ -0,0 +1,24 @@
import {
addTextPreviewWidgets,
updateTextPreviewWidgets
} from '@/extensions/core/textPreviewWidgets'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.saveText',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'SaveText') return
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
addTextPreviewWidgets(this)
}
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, [message])
updateTextPreviewWidgets(this, message)
}
}
})

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
interface MockWidget {
name: string
options: Record<string, unknown>
value?: unknown
serialize?: boolean
}
vi.mock('@/scripts/app', () => ({ app: { rootGraph: { id: 'graph-1' } } }))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
resolveNodeRootGraphId: () => 'graph-1'
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue',
() => ({
default: {}
})
)
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: () => undefined })
}))
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: class {
name: string
options: Record<string, unknown>
type: string
serialize?: boolean
constructor(obj: {
name: string
options: Record<string, unknown>
type: string
}) {
this.name = obj.name
this.options = obj.options
this.type = obj.type
}
},
addWidget: (node: { widgets?: MockWidget[] }, widget: MockWidget) => {
node.widgets = node.widgets ?? []
node.widgets.push(widget)
}
}))
vi.mock('@/scripts/widgets', () => ({
ComfyWidgets: {
BOOLEAN: (node: { widgets?: MockWidget[] }, name: string) => {
const widget: MockWidget = { name, options: {}, value: false }
node.widgets = node.widgets ?? []
node.widgets.push(widget)
return { widget }
}
}
}))
const { addTextPreviewWidgets, updateTextPreviewWidgets } =
await import('./textPreviewWidgets')
function makeNode(): LGraphNode & { widgets: MockWidget[] } {
return { id: '1', widgets: [] } as unknown as LGraphNode & {
widgets: MockWidget[]
}
}
describe('addTextPreviewWidgets', () => {
it('adds a non-serialized preview widget and a non-serialized mode toggle', () => {
const node = makeNode()
addTextPreviewWidgets(node)
const preview = node.widgets.find((w) => w.name === 'preview_text')
const mode = node.widgets.find((w) => w.name === 'preview_mode')
expect(preview?.type).toBe('textPreview')
expect(preview?.serialize).toBe(false)
expect(preview?.options.serialize).toBe(false)
expect(mode?.options.serialize).toBe(false)
})
})
describe('updateTextPreviewWidgets', () => {
let node: LGraphNode & { widgets: MockWidget[] }
beforeEach(() => {
node = makeNode()
node.widgets.push({ name: 'preview_text', options: {}, value: '' })
})
it('joins array text into the preview widget value', () => {
updateTextPreviewWidgets(node, { text: ['a', 'b'] })
expect(node.widgets[0].value).toBe('a\n\nb')
})
it('writes a plain string message as-is', () => {
updateTextPreviewWidgets(node, { text: 'hello' })
expect(node.widgets[0].value).toBe('hello')
})
})

View File

@@ -0,0 +1,80 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const PREVIEW_WIDGET_NAME = 'preview_text'
const MODE_WIDGET_NAME = 'preview_mode'
const inputSpecTextPreview: CustomInputSpec = {
name: PREVIEW_WIDGET_NAME,
type: 'TEXT_PREVIEW',
isPreview: true
}
export function addTextPreviewWidgets(node: LGraphNode) {
const widgetStore = useWidgetValueStore()
let fallbackValue = ''
const previewGraphId = () => resolveNodeRootGraphId(node, app.rootGraph.id)
const preview = new ComponentWidgetImpl<string | object>({
node,
name: PREVIEW_WIDGET_NAME,
component: WidgetTextPreview,
inputSpec: inputSpecTextPreview,
type: 'textPreview',
options: {
serialize: false,
hideInPanel: true,
getMinHeight: () => 60,
getValue: () => {
const stored = widgetStore.getWidget(
previewGraphId(),
node.id,
PREVIEW_WIDGET_NAME
)?.value
return typeof stored === 'string' ? stored : fallbackValue
},
setValue: (value: string | object) => {
fallbackValue = typeof value === 'string' ? value : ''
const state = widgetStore.getWidget(
previewGraphId(),
node.id,
PREVIEW_WIDGET_NAME
)
if (state) state.value = fallbackValue
}
}
})
preview.serialize = false
addWidget(node, preview)
const modeWidget = ComfyWidgets['BOOLEAN'](
node,
MODE_WIDGET_NAME,
[
'BOOLEAN',
{ label_on: 'Markdown', label_off: 'Plain text', default: false }
],
app
).widget
modeWidget.options.serialize = false
modeWidget.serialize = false
}
export function updateTextPreviewWidgets(
node: LGraphNode,
message: { text?: string | string[] }
) {
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
if (!preview) return
const text = message.text ?? ''
preview.value = Array.isArray(text) ? text.join('\n\n') : text
}

View 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'
)
})
})

View File

@@ -157,15 +157,28 @@ export const i18n = createI18n({
export const { t, te, d } = i18n.global
const { tm } = i18n.global
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)
}
}
/**
@@ -178,6 +191,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)
}

View File

@@ -826,6 +826,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this._lowQualityZoomThreshold > 0) {
this._isLowQuality = scale < this._lowQualityZoomThreshold
}
this.setDirty(true, true)
}
// Initialize link renderer if graph is available

View File

@@ -1,5 +1,6 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { BoundingBox } from '@/types/boundingBoxes'
import type {
CanvasColour,
@@ -45,6 +46,14 @@ export interface IWidgetOptions<TValues = unknown> {
socketless?: boolean
/** If `true`, the widget will not be rendered by the Vue renderer. */
canvasOnly?: boolean
/**
* If `true`, the widget still renders on the node but is omitted from the
* right side panel. Unlike {@link IWidgetOptions.canvasOnly}, the node body
* keeps rendering it via the Vue renderer. Used for widgets that hold
* non-syncable state (e.g. a Three.js viewport) where a second instance in
* the panel would diverge from the one on the node.
*/
hideInPanel?: boolean
/** Used as a temporary override for determining the asset type in vue mode*/
nodeType?: string
@@ -140,6 +149,8 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IBoundingBoxesWidget
| IColorsWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -342,6 +353,19 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
value: string
}
export interface IBoundingBoxesWidget extends IBaseWidget<
BoundingBox[],
'boundingboxes'
> {
type: 'boundingboxes'
value: BoundingBox[]
}
export interface IColorsWidget extends IBaseWidget<string[], 'colors'> {
type: 'colors'
value: string[]
}
export interface RangeValue {
min: number
max: number

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('BoundingBoxesWidget', () => {
it('has the boundingboxes type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new BoundingBoxesWidget(
{
type: 'boundingboxes',
name: 'editor_state',
value: [],
options: {},
y: 0
},
node
)
expect(widget.type).toBe('boundingboxes')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -0,0 +1,16 @@
import type { IBoundingBoxesWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class BoundingBoxesWidget
extends BaseWidget<IBoundingBoxesWidget>
implements IBoundingBoxesWidget
{
override type = 'boundingboxes' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Bounding Boxes')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { ColorsWidget } from './ColorsWidget'
function fakeCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
fillStyle: '',
strokeStyle: '',
font: '',
textAlign: '',
textBaseline: ''
} as unknown as CanvasRenderingContext2D
}
describe('ColorsWidget', () => {
it('has the colors type and draws the Vue-only placeholder', () => {
const node = new LGraphNode('Test')
const widget = new ColorsWidget(
{ type: 'colors', name: 'palette', value: [], options: {}, y: 0 },
node
)
expect(widget.type).toBe('colors')
const ctx = fakeCtx()
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
expect(ctx.fillText).toHaveBeenCalled()
expect(() => widget.onClick({} as never)).not.toThrow()
})
})

View File

@@ -0,0 +1,16 @@
import type { IColorsWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class ColorsWidget
extends BaseWidget<IColorsWidget>
implements IColorsWidget
{
override type = 'colors' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Colors')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -21,6 +21,8 @@ import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
import { ColorsWidget } from './ColorsWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { ImageCropWidget } from './ImageCropWidget'
@@ -62,6 +64,8 @@ export type WidgetTypeMap = {
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
boundingboxes: BoundingBoxesWidget
colors: ColorsWidget
[key: string]: BaseWidget
}
@@ -144,6 +148,10 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
case 'boundingboxes':
return toClass(BoundingBoxesWidget, narrowedWidget, node)
case 'colors':
return toClass(ColorsWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -45,6 +45,7 @@
"errorLoadingImage": "Error loading image",
"errorLoadingVideo": "Error loading video",
"failedToDownloadImage": "Failed to download image",
"failedToDownloadFile": "Failed to download file",
"failedToDownloadVideo": "Failed to download video",
"calculatingDimensions": "Calculating dimensions",
"import": "Import",
@@ -58,6 +59,7 @@
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"textFailedToLoad": "Text failed to load",
"liveSamplingPreview": "Live sampling preview",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
@@ -377,6 +379,35 @@
"collapseAll": "Collapse all",
"expandAll": "Expand all"
},
"hdrViewer": {
"title": "HDR Viewer",
"openInHdrViewer": "Open in HDR Viewer",
"hdrImage": "HDR image",
"failedToLoad": "Failed to load HDR image",
"exposure": "Exposure",
"normalizeExposure": "Auto exposure",
"channel": "Channel",
"channels": {
"rgb": "RGB",
"r": "R",
"g": "G",
"b": "B",
"a": "Alpha",
"luminance": "Luminance"
},
"sourceGamut": "Source gamut",
"dither": "Dither",
"clipWarnings": "Clip warnings",
"fitView": "Fit",
"histogram": "Histogram",
"resolution": "Resolution",
"min": "Min",
"max": "Max",
"mean": "Mean",
"stdDev": "Std dev",
"nan": "NaN",
"inf": "Inf"
},
"manager": {
"title": "Nodes Manager",
"nodePackInfo": "Node Pack Info",
@@ -2064,6 +2095,22 @@
"monotone_cubic": "Smooth",
"linear": "Linear"
},
"boundingBoxes": {
"clearAll": "Clear all",
"clickRegionToEdit": "Click a region to edit it.",
"typeObj": "obj",
"typeText": "text",
"textLabel": "Text",
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette",
"grid": "Grid"
},
"palette": {
"addColor": "Add a color",
"swatchTitle": "Click edit · drag reorder · right-click remove"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
@@ -3667,28 +3714,321 @@
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Required input missing",
"toastMessage": "{nodeName} is missing a required input: {inputName}"
},
"bad_linked_input": {
"title": "Invalid connection",
"message": "A node connection could not be read correctly.",
"details": "{nodeName} has an invalid connection for {inputName}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid connection",
"toastMessage": "{nodeName} has an invalid connection for {inputName}."
},
"return_type_mismatch": {
"title": "Invalid connection",
"message": "Connected nodes are using incompatible input and output types.",
"details": "{nodeName} has an incompatible connection for {inputName}.",
"detailsWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid connection",
"toastMessage": "{nodeName} has an incompatible connection for {inputName}.",
"toastMessageWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}."
},
"invalid_input_type": {
"title": "Invalid input",
"message": "An input value has the wrong type.",
"details": "{nodeName} couldn't convert {inputName} to the expected type.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} couldn't convert {inputName} to the expected type.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}."
},
"value_smaller_than_min": {
"title": "Input out of range",
"message": "Some input values are outside the allowed range.",
"details": "{nodeName} has a value below the minimum for {inputName}.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Input out of range",
"toastMessage": "{nodeName} has a value below the minimum for {inputName}.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}."
},
"value_bigger_than_max": {
"title": "Input out of range",
"message": "Some input values are outside the allowed range.",
"details": "{nodeName} has a value above the maximum for {inputName}.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Input out of range",
"toastMessage": "{nodeName} has a value above the maximum for {inputName}.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}."
},
"value_not_in_list": {
"title": "Invalid input",
"message": "Some input values are not available for this node.",
"details": "{nodeName} has an unsupported value for {inputName}.",
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} has an unsupported value for {inputName}.",
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available."
},
"custom_validation_failed": {
"title": "Invalid input",
"message": "A node rejected one or more input values.",
"details": "{nodeName} rejected the value for {inputName}.",
"detailsWithRawDetails": "{nodeName} failed custom validation: {rawDetails}",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} rejected the value for {inputName}."
},
"exception_during_inner_validation": {
"title": "Validation failed",
"message": "The workflow couldn't validate a connected node.",
"details": "{nodeName} couldn't validate {inputName}.",
"detailsWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}",
"itemLabel": "{nodeName} - {inputName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} couldn't validate {inputName}.",
"toastMessageWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}"
},
"exception_during_validation": {
"title": "Validation failed",
"message": "The workflow could not be validated because a node validation check failed unexpectedly.",
"details": "{nodeName} failed during validation.",
"detailsWithRawDetails": "{nodeName} failed during validation: {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} failed during validation.",
"toastMessageWithRawDetails": "{nodeName} failed during validation: {rawDetails}"
},
"dependency_cycle": {
"title": "Invalid workflow",
"message": "The workflow has a circular node connection.",
"details": "{nodeName} is part of a circular connection.",
"detailsWithRawDetails": "{nodeName} is part of a circular connection: {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid workflow",
"toastMessage": "{nodeName} is part of a circular connection."
},
"image_not_loaded": {
"title": "Image not loaded",
"message": "The system couldn't load this image.",
"details": "The image for {nodeName} couldn't be loaded. Try adding it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Input image couldn't be loaded",
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
}
},
"runtimeErrors": {
"execution_failed": {
"title": "Execution failed",
"message": "Node threw an error during execution.",
"itemLabel": "{nodeName}",
"toastTitle": "{nodeName} failed",
"toastMessageLocal": "This node threw an error during execution. Check its inputs or try a different configuration.",
"toastMessageCloud": "This node threw an error during execution. Check its inputs or try a different configuration. No credits charged."
"toastMessage": "This node threw an error during execution. Check its inputs or try a different configuration."
},
"image_not_loaded": {
"title": "Image not loaded",
"message": "The system couldn't load this image.",
"itemLabel": "{nodeName}",
"toastTitle": "Input image couldn't be loaded",
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
},
"out_of_memory": {
"title": "Generation failed",
"message": "Not enough GPU memory. Try reducing image resolution or batch size and run again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation failed",
"toastMessage": "Not enough GPU memory. Try reducing image resolution or batch size and run again."
},
"content_blocked": {
"title": "Content blocked",
"message": "This request was blocked by the content moderation system. Try changing the prompt or inputs.",
"itemLabel": "{nodeName}",
"toastTitle": "Content blocked",
"toastMessage": "This request was blocked by the content moderation system. Try changing the prompt or inputs."
},
"access_required": {
"title": "Access required",
"message": "This run requires access that is not available for the current account.",
"itemLabel": "{nodeName}",
"toastTitle": "Access required",
"toastMessage": "This run requires access that is not available for the current account."
},
"model_access_error": {
"title": "Model access required",
"message": "One or more required models could not be accessed.",
"itemLabel": "{nodeName}",
"toastTitle": "Model access required",
"toastMessage": "One or more required models could not be accessed."
},
"invalid_clip_input": {
"title": "Invalid CLIP input",
"message": "The CLIP input is missing or invalid. Check the connected checkpoint or CLIP loader.",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid CLIP input",
"toastMessage": "{nodeName} has a missing or invalid CLIP input."
},
"invalid_prompt": {
"title": "Prompt is empty",
"message": "Enter a prompt before running this workflow.",
"itemLabel": "{nodeName}",
"toastTitle": "Prompt is empty",
"toastMessage": "Enter a prompt before running this workflow."
},
"invalid_workflow_request": {
"title": "Invalid workflow request",
"message": "The workflow request is invalid. Check the workflow and try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid workflow request",
"toastMessage": "The workflow request is invalid. Check the workflow and try again."
},
"insufficient_credits": {
"title": "Insufficient credits",
"message": "Add credits to your account to use this node.",
"itemLabel": "{nodeName}",
"toastTitle": "Insufficient credits",
"toastMessage": "Add credits to your account to use this node."
},
"workspace_insufficient_credits": {
"title": "Insufficient credits",
"message": "Add credits to your workspace to continue.",
"itemLabel": "{nodeName}",
"toastTitle": "Insufficient credits",
"toastMessage": "Add credits to your workspace to continue."
},
"subscription_required": {
"title": "Subscription required",
"message": "Subscribe to a plan to continue running this workflow.",
"itemLabel": "{nodeName}",
"toastTitle": "Subscription required",
"toastMessage": "Subscribe to a plan to continue running this workflow."
},
"subscription_upgrade_required": {
"title": "Subscription upgrade required",
"message": "Upgrade your subscription to use the private models in this workflow.",
"details": "Private models require a subscription upgrade: {modelNames}",
"itemLabel": "{nodeName}",
"toastTitle": "Subscription upgrade required",
"toastMessage": "Upgrade your subscription to use these private models: {modelNames}."
},
"model_download_failed": {
"title": "Model download failed",
"message": "A model could not be downloaded. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Model download failed",
"toastMessage": "A model could not be downloaded. Try again."
},
"unexpected_service_error": {
"title": "Service error",
"message": "The service encountered an unexpected error. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Service error",
"toastMessage": "The service encountered an unexpected error. Try again."
},
"request_failed": {
"title": "Request failed",
"message": "The request failed before the run could complete. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Request failed",
"toastMessage": "The request failed before the run could complete. Try again."
},
"run_start_failed": {
"title": "Run could not start",
"message": "The run could not be started. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Run could not start",
"toastMessage": "The run could not be started. Try again."
},
"run_ended_unexpectedly": {
"title": "Run ended unexpectedly",
"message": "The run ended unexpectedly. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Run ended unexpectedly",
"toastMessage": "The run ended unexpectedly. Try again."
},
"sign_in_required": {
"title": "Sign in required",
"message": "Partner nodes require a Comfy account. Sign in to continue.",
"itemLabel": "{nodeName}",
"toastTitle": "Sign in to use this node",
"toastMessage": "Partner nodes require a Comfy account. Sign in to continue."
},
"rate_limited": {
"title": "Servers are busy",
"message": "High demand right now. Try again in a moment.",
"itemLabel": "{nodeName}",
"toastTitle": "Servers are busy",
"toastMessage": "High demand right now. Try again in a moment."
},
"timeout": {
"title": "Generation timed out",
"message": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation timed out",
"toastMessage": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again."
},
"generation_stalled": {
"title": "Generation stalled",
"message": "This workflow stopped making progress. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation stalled",
"toastMessage": "This workflow stopped making progress. Try running it again."
},
"preprocessing_failed": {
"title": "Preparation failed",
"message": "The workflow could not be prepared. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Preparation failed",
"toastMessage": "The workflow could not be prepared. Try running it again."
},
"preprocessing_timeout": {
"title": "Preparation timed out",
"message": "The workflow took too long to prepare. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Preparation timed out",
"toastMessage": "The workflow took too long to prepare. Try running it again."
},
"server_crashed": {
"title": "Server crashed",
"message": "The server stopped while running this workflow. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Server crashed",
"toastMessage": "The server stopped while running this workflow. Try again."
},
"server_busy": {
"title": "Servers are busy",
"message": "The servers are busy right now. Try again in a moment.",
"itemLabel": "{nodeName}",
"toastTitle": "Servers are busy",
"toastMessage": "The servers are busy right now. Try again in a moment."
}
},
"promptErrors": {
"prompt_no_outputs": {
"title": "Prompt has no outputs",
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
},
"no_prompt": {
"title": "Workflow data is empty",
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
},
"server_error_local": {
"title": "Server error",
"desc": "The server encountered an unexpected error. Please check the server logs."
},
"server_error_cloud": {
"title": "Server error",
"desc": "The server encountered an unexpected error. Please try again later."
},
"missing_node_type": {
"title": "Missing node type",
"desc": "A node type is missing or unavailable. The workflow may be corrupted or require a custom node."
},
"prompt_outputs_failed_validation": {
"title": "Prompt validation failed",
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
}
}
},

View 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)
})
})

View File

@@ -6,6 +6,7 @@ import { defineComponent, nextTick, onMounted, ref } from 'vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as FormatUtil from '@/utils/formatUtil'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
@@ -21,14 +22,11 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
supportsWorkflowMetadata: () => true
}))
vi.mock('@/utils/formatUtil', () => ({
vi.mock('@/utils/formatUtil', async (importOriginal) => ({
...(await importOriginal<typeof FormatUtil>()),
isPreviewableMediaType: () => true
}))
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: () => ({ nodeType: 'LoadImage' })
}))
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAssets: vi.fn(),
@@ -105,7 +103,7 @@ interface MediaAssetContextMenuExposed {
let capturedRef: MediaAssetContextMenuExposed | null = null
function mountComponent() {
function mountComponent(targetAsset: AssetItem = asset) {
const onHide = vi.fn()
const { container, unmount } = render(
defineComponent({
@@ -115,7 +113,7 @@ function mountComponent() {
onMounted(() => {
capturedRef = menuRef.value
})
return { menuRef, asset, onHide }
return { menuRef, asset: targetAsset, onHide }
},
template:
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
@@ -151,10 +149,12 @@ type MenuItemWithCommand = MenuItem & {
command: NonNullable<MenuItem['command']>
}
function findMenuItem(label: string): MenuItem | undefined {
return capturedMenu.model.find((item) => item.label === label)
}
function findDownloadMenuItem(): MenuItemWithCommand {
const downloadItem = capturedMenu.model.find(
(item) => item.label === 'mediaAsset.actions.download'
)
const downloadItem = findMenuItem('mediaAsset.actions.download')
if (!downloadItem?.command) {
throw new Error('Download menu item or command was not registered')
}
@@ -184,6 +184,31 @@ describe('MediaAssetContextMenu', () => {
unmount()
})
it('shows insert-as-node for assets with a loader node', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)
expect(
findMenuItem('mediaAsset.actions.insertAsNodeInWorkflow')
).toBeDefined()
unmount()
})
it('hides insert-as-node for text assets without a loader node', async () => {
const { container, unmount } = mountComponent({
...asset,
name: 'result.txt'
})
await showMenu(container)
expect(
findMenuItem('mediaAsset.actions.insertAsNodeInWorkflow')
).toBeUndefined()
unmount()
})
it('routes Download through downloadAssets so multi-output jobs zip', async () => {
const { container, unmount } = mountComponent()
await showMenu(container)

View File

@@ -93,16 +93,10 @@ useDismissableOverlay({
})
const showAddToWorkflow = computed(() => {
// Output assets can always be added
if (assetType === 'output') return true
// Input assets: check if file type is supported by loader nodes
if (assetType === 'input' && asset?.name) {
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
}
return false
// Only file types with a loader node (image/video/audio) can be inserted
if (!asset?.name) return false
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
})
const showWorkflowActions = computed(() => {

View File

@@ -30,7 +30,8 @@ const labelByType: Record<string, string> = {
image: 'sideToolbar.mediaAssets.filterImage',
video: 'sideToolbar.mediaAssets.filterVideo',
audio: 'sideToolbar.mediaAssets.filterAudio',
'3d': 'sideToolbar.mediaAssets.filter3D'
'3d': 'sideToolbar.mediaAssets.filter3D',
text: 'sideToolbar.mediaAssets.filterText'
}
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
@@ -38,11 +39,11 @@ function getCheckbox(type: keyof typeof labelByType): HTMLElement {
}
describe('MediaAssetFilterMenu', () => {
it('renders all four media-type checkboxes', () => {
it('renders all media-type checkboxes', () => {
renderMenu()
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(4)
expect(checkboxes).toHaveLength(5)
for (const type of Object.keys(labelByType)) {
expect(getCheckbox(type)).toBeTruthy()
}

View File

@@ -54,7 +54,8 @@ const filters = [
{ type: 'image', label: 'sideToolbar.mediaAssets.filterImage' },
{ type: 'video', label: 'sideToolbar.mediaAssets.filterVideo' },
{ type: 'audio', label: 'sideToolbar.mediaAssets.filterAudio' },
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' }
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' },
{ type: 'text', label: 'sideToolbar.mediaAssets.filterText' }
]
const toggleMediaType = (type: string) => {

View File

@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTextTop from './MediaTextTop.vue'
function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'result.txt',
tags: [],
kind: 'text',
src: 'http://example.com/result.txt',
...overrides
} as AssetMeta
}
describe('MediaTextTop', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('shows a snippet of the fetched text', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('hello world')
})
vi.stubGlobal('fetch', fetchMock)
render(MediaTextTop, { props: { asset: makeAsset() } })
expect(await screen.findByText('hello world')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith('http://example.com/result.txt')
})
it('prefers preview_url over src', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('preview text')
})
vi.stubGlobal('fetch', fetchMock)
render(MediaTextTop, {
props: {
asset: makeAsset({ preview_url: 'http://server/preview.txt' })
}
})
expect(await screen.findByText('preview text')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith('http://server/preview.txt')
})
})

View File

@@ -1,9 +1,37 @@
<template>
<div class="relative size-full overflow-hidden rounded-sm">
<div
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
class="size-full bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
>
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
<p
v-if="snippet"
class="m-0 size-full overflow-hidden p-2 text-left text-xs/4 wrap-break-word whitespace-pre-wrap text-base-foreground"
>
{{ snippet }}
</p>
<div v-else class="flex size-full items-center justify-center">
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const MAX_SNIPPET_LENGTH = 1000
const { asset } = defineProps<{
asset: AssetMeta
}>()
const { textContent } = useTextFileContent(() => ({
url: asset.preview_url || asset.src
}))
const snippet = computed(() => textContent.value.slice(0, MAX_SNIPPET_LENGTH))
</script>

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccountPreconditionDialog } from './useAccountPreconditionDialog'
const mockDialogService = {
showApiNodesSignInDialog: vi.fn(),
showSubscriptionRequiredDialog: vi.fn(),
showTopUpCreditsDialog: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
describe('useAccountPreconditionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('routes a sign-in precondition to the API sign-in dialog with the node type', () => {
useAccountPreconditionDialog().open('sign_in', { nodeType: 'ApiNode' })
expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([
'ApiNode'
])
expect(
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled()
})
it('routes a sign-in precondition with no node type to an empty list', () => {
useAccountPreconditionDialog().open('sign_in')
expect(mockDialogService.showApiNodesSignInDialog).toHaveBeenCalledWith([])
})
it('routes a subscription precondition to the subscription dialog', () => {
useAccountPreconditionDialog().open('subscription')
expect(
mockDialogService.showSubscriptionRequiredDialog
).toHaveBeenCalledTimes(1)
expect(mockDialogService.showApiNodesSignInDialog).not.toHaveBeenCalled()
expect(mockDialogService.showTopUpCreditsDialog).not.toHaveBeenCalled()
})
it('routes a credit precondition to the top-up dialog', () => {
useAccountPreconditionDialog().open('credits', { nodeType: 'PartnerNode' })
expect(mockDialogService.showTopUpCreditsDialog).toHaveBeenCalledWith({
isInsufficientCredits: true
})
expect(
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,38 @@
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
import { useDialogService } from '@/services/dialogService'
interface AccountPreconditionContext {
/** Node type that triggered the precondition, used as modal context. */
nodeType?: string
}
// Routes a resolved account precondition to its dedicated modal. This is the
// single seam where FE-978 attaches role-aware (member vs owner) subscription
// content: the `subscription` branch resolves to the subscription dialog, whose
// inner content FE-978 specializes for cancelled/inactive team states.
export function useAccountPreconditionDialog() {
const dialogService = useDialogService()
function open(
precondition: AccountPrecondition,
context: AccountPreconditionContext = {}
): void {
switch (precondition) {
case 'sign_in':
void dialogService.showApiNodesSignInDialog(
context.nodeType ? [context.nodeType] : []
)
return
case 'subscription':
void dialogService.showSubscriptionRequiredDialog()
return
case 'credits':
void dialogService.showTopUpCreditsDialog({
isInsufficientCredits: true
})
return
}
}
return { open }
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
isAccountPreconditionCatalogId,
preconditionForCatalogId,
resolveAccountPrecondition
} from './accountPreconditionRouting'
import {
EXECUTION_FAILED_CATALOG_ID,
INSUFFICIENT_CREDITS_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
describe('resolveAccountPrecondition', () => {
it('classifies a sign-in error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage: 'Unauthorized: Please login first to use this node.'
})
).toBe('sign_in')
})
it('classifies an inactive-subscription error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'InactiveSubscriptionError',
exceptionMessage:
'User has no active subscription. Please subscribe to a plan to continue.'
})
).toBe('subscription')
})
it('classifies the queue paywall (PAYMENT_REQUIRED) as a subscription precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Subscription required to queue workflows'
})
).toBe('subscription')
})
it('classifies a subscription-upgrade error as a subscription precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage:
'the following private models require a subscription upgrade: flux-pro'
})
).toBe('subscription')
})
it('classifies an account credit error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'InsufficientFundsError',
exceptionMessage:
'Payment Required: Please add credits to your account to use this node.'
})
).toBe('credits')
})
it('classifies a workspace credit error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage:
'Payment Required: Please add credits to your workspace to continue.'
})
).toBe('credits')
})
it('returns undefined for an ordinary workflow error', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA out of memory'
})
).toBeUndefined()
})
})
describe('preconditionForCatalogId / isAccountPreconditionCatalogId', () => {
it('maps every precondition catalog id', () => {
expect(preconditionForCatalogId(SIGN_IN_REQUIRED_CATALOG_ID)).toBe(
'sign_in'
)
expect(preconditionForCatalogId(SUBSCRIPTION_REQUIRED_CATALOG_ID)).toBe(
'subscription'
)
expect(
preconditionForCatalogId(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID)
).toBe('subscription')
expect(preconditionForCatalogId(INSUFFICIENT_CREDITS_CATALOG_ID)).toBe(
'credits'
)
expect(
preconditionForCatalogId(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
).toBe('credits')
})
it('does not treat a workflow error catalog id as a precondition', () => {
expect(
preconditionForCatalogId(EXECUTION_FAILED_CATALOG_ID)
).toBeUndefined()
expect(isAccountPreconditionCatalogId(EXECUTION_FAILED_CATALOG_ID)).toBe(
false
)
expect(isAccountPreconditionCatalogId(undefined)).toBe(false)
})
})

View File

@@ -0,0 +1,44 @@
import {
INSUFFICIENT_CREDITS_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
import type { RuntimeErrorInfo } from './runtimeErrorMatcher'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
// Account preconditions are gating states (sign-in, subscription, credits) that
// must open their own modal instead of surfacing as a workflow error. They are
// excluded from the error panel and the error count.
export type AccountPrecondition = 'sign_in' | 'subscription' | 'credits'
const CATALOG_ID_TO_PRECONDITION = new Map<string, AccountPrecondition>([
[SIGN_IN_REQUIRED_CATALOG_ID, 'sign_in'],
[SUBSCRIPTION_REQUIRED_CATALOG_ID, 'subscription'],
[SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, 'subscription'],
[INSUFFICIENT_CREDITS_CATALOG_ID, 'credits'],
[WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID, 'credits']
])
export function preconditionForCatalogId(
catalogId: string | undefined
): AccountPrecondition | undefined {
if (!catalogId) return undefined
return CATALOG_ID_TO_PRECONDITION.get(catalogId)
}
export function isAccountPreconditionCatalogId(
catalogId: string | undefined
): boolean {
return preconditionForCatalogId(catalogId) !== undefined
}
// Classifies a single runtime error payload into the account precondition it
// represents, or `undefined` when it is an ordinary workflow error.
export function resolveAccountPrecondition(
info: RuntimeErrorInfo
): AccountPrecondition | undefined {
const match = resolveRuntimeCatalogMatch(info)
return preconditionForCatalogId(match?.catalogId)
}

View File

@@ -0,0 +1,40 @@
import { t, te } from '@/i18n'
// Shared i18n helpers for error catalog resolvers. These preserve the raw API
// message/details as fallbacks when a catalog key is not available. Keep this
// module folder-internal so UI code only consumes resolved display fields.
export interface ErrorResolveContext {
isCloud?: boolean
nodeDisplayName?: string
}
export type CatalogParams = Record<string, string | number>
export function translateCatalogMessage(
key: string,
fallback: string,
params?: CatalogParams
): string {
if (te(key)) return params ? t(key, params) : t(key)
if (!params) return fallback
return fallback.replace(/\{(\w+)\}/g, (match, paramName) =>
params[paramName] === undefined ? match : String(params[paramName])
)
}
export function translateOptionalCatalogMessage(
key: string,
fallback?: string,
params?: CatalogParams
): string | undefined {
if (te(key)) return params ? t(key, params) : t(key)
return fallback?.trim() ? fallback : undefined
}
export function normalizeNodeName(nodeDisplayName: string | undefined): string {
return (
nodeDisplayName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}

View File

@@ -0,0 +1,32 @@
// FE-resolved catalog IDs that either normalize multiple sources or do not map
// 1:1 to an API error type. Simple validation mappings stay with the validation
// resolver.
export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection'
export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
export const CONTENT_BLOCKED_CATALOG_ID = 'content_blocked'
export const ACCESS_REQUIRED_CATALOG_ID = 'access_required'
export const MODEL_ACCESS_ERROR_CATALOG_ID = 'model_access_error'
export const INVALID_CLIP_INPUT_CATALOG_ID = 'invalid_clip_input'
export const INVALID_PROMPT_CATALOG_ID = 'invalid_prompt'
export const INVALID_WORKFLOW_REQUEST_CATALOG_ID = 'invalid_workflow_request'
export const INSUFFICIENT_CREDITS_CATALOG_ID = 'insufficient_credits'
export const WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID =
'workspace_insufficient_credits'
export const SUBSCRIPTION_REQUIRED_CATALOG_ID = 'subscription_required'
export const SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID =
'subscription_upgrade_required'
export const MODEL_DOWNLOAD_FAILED_CATALOG_ID = 'model_download_failed'
export const UNEXPECTED_SERVICE_ERROR_CATALOG_ID = 'unexpected_service_error'
export const REQUEST_FAILED_CATALOG_ID = 'request_failed'
export const RUN_START_FAILED_CATALOG_ID = 'run_start_failed'
export const RUN_ENDED_UNEXPECTEDLY_CATALOG_ID = 'run_ended_unexpectedly'
export const SIGN_IN_REQUIRED_CATALOG_ID = 'sign_in_required'
export const RATE_LIMITED_CATALOG_ID = 'rate_limited'
export const TIMEOUT_CATALOG_ID = 'timeout'
export const GENERATION_STALLED_CATALOG_ID = 'generation_stalled'
export const PREPROCESSING_FAILED_CATALOG_ID = 'preprocessing_failed'
export const PREPROCESSING_TIMEOUT_CATALOG_ID = 'preprocessing_timeout'
export const SERVER_CRASHED_CATALOG_ID = 'server_crashed'
export const SERVER_BUSY_CATALOG_ID = 'server_busy'

File diff suppressed because it is too large Load Diff

View File

@@ -1,227 +1,13 @@
import type {
MissingErrorMessageSource,
NodeValidationError,
ResolvedErrorMessage,
ResolvedMissingErrorMessage,
RunErrorMessageSource
} from './types'
import { st, t, te } from '@/i18n'
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
const REQUIRED_INPUT_MISSING_CATALOG_ID = 'missing_connection'
const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error'
])
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
interface ErrorResolveContext {
isCloud?: boolean
nodeDisplayName?: string
}
function translateCatalogMessage(
key: string,
fallback: string,
params?: Record<string, string | number>
): string {
if (te(key)) return params ? t(key, params) : t(key)
if (!params) return fallback
return fallback.replace(/\{(\w+)\}/g, (match, paramName) =>
params[paramName] === undefined ? match : String(params[paramName])
)
}
function normalizeNodeName(nodeDisplayName: string | undefined): string {
return (
nodeDisplayName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name ?? error.details
return (
inputName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
)
}
function isRequiredInputMissing(
error: NodeValidationError
): error is NodeValidationError & { type: typeof REQUIRED_INPUT_MISSING_TYPE } {
return error.type === REQUIRED_INPUT_MISSING_TYPE
}
function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
if (!isRequiredInputMissing(error)) return {}
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const keyPrefix = 'errorCatalog.validationErrors.required_input_missing'
return {
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
displayTitle: translateCatalogMessage(
`${keyPrefix}.title`,
'Missing connection'
),
displayMessage: translateCatalogMessage(
`${keyPrefix}.message`,
'Required input slots have no connection feeding them.'
),
displayDetails: translateCatalogMessage(
`${keyPrefix}.details`,
'{nodeName} is missing a required input: {inputName}',
{ nodeName, inputName }
),
displayItemLabel: translateCatalogMessage(
`${keyPrefix}.itemLabel`,
'{nodeName} - {inputName}',
{ nodeName, inputName }
),
toastTitle: translateCatalogMessage(
`${keyPrefix}.toastTitle`,
'Required input missing'
),
toastMessage: translateCatalogMessage(
`${keyPrefix}.toastMessage`,
'{nodeName} is missing a required input: {inputName}',
{ nodeName, inputName }
)
}
}
function resolveExecutionErrorMessage(
context: ErrorResolveContext
): ResolvedErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const keyPrefix = 'errorCatalog.runtimeErrors.execution_failed'
const toastMessageKey = context.isCloud
? `${keyPrefix}.toastMessageCloud`
: `${keyPrefix}.toastMessageLocal`
const toastMessageFallback = context.isCloud
? 'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
: 'This node threw an error during execution. Check its inputs or try a different configuration.'
return {
catalogId: EXECUTION_FAILED_CATALOG_ID,
displayItemLabel: translateCatalogMessage(
`${keyPrefix}.itemLabel`,
nodeName,
{
nodeName
}
),
toastTitle: translateCatalogMessage(
`${keyPrefix}.toastTitle`,
'{nodeName} failed',
{ nodeName }
),
toastMessage: translateCatalogMessage(
toastMessageKey,
toastMessageFallback,
{ nodeName }
)
}
}
function resolvePromptErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
context: ErrorResolveContext
): ResolvedErrorMessage {
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
const errorTypeKey =
error.type === 'server_error'
? context.isCloud
? 'server_error_cloud'
: 'server_error_local'
: error.type
return {
displayMessage: st(
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
error.message
)
}
}
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
}
export function resolveMissingErrorMessage(
source: MissingErrorMessageSource
): ResolvedMissingErrorMessage {
switch (source.kind) {
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
}
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
}
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
}
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
}
}
}
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export function resolveRunErrorMessage(
source: RunErrorMessageSource
@@ -231,14 +17,13 @@ export function resolveRunErrorMessage(
return resolveNodeValidationErrorMessage(source.error, {
nodeDisplayName: source.nodeDisplayName
})
case 'execution':
return resolveExecutionErrorMessage({
isCloud: source.isCloud,
nodeDisplayName: source.nodeDisplayName
})
case 'prompt':
return resolvePromptErrorMessage(source.error, {
isCloud: source.isCloud
})
case 'execution':
return resolveExecutionErrorMessage(source.error, {
nodeDisplayName: source.nodeDisplayName
})
}
}

View File

@@ -0,0 +1,39 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
import type { ErrorResolveContext } from './catalogI18n'
import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
type ExecutionErrorResolveContext = Pick<ErrorResolveContext, 'nodeDisplayName'>
// Resolves node-scoped runtime failures while preserving raw API fields.
export function resolveExecutionErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
context: ExecutionErrorResolveContext
): ResolvedErrorMessage {
const exceptionMessage = error.exception_message.trim()
const match = resolveRuntimeCatalogMatch({
exceptionType: error.exception_type,
exceptionMessage
})
if (!match) {
return resolveRuntimeCatalogCopy(
EXECUTION_FAILED_CATALOG_ID,
error.exception_message,
context,
{ includeItemLabel: true }
)
}
return resolveRuntimeCatalogCopy(
match.catalogId,
error.exception_message,
context,
{
includeItemLabel: true,
params: match.params,
detailsFallback: match.detailsFallback
}
)
}

View File

@@ -0,0 +1,78 @@
import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { st, t } from '@/i18n'
// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These
// are grouped catalog messages rather than individual execution error items.
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
}
export function resolveMissingErrorMessage(
source: MissingErrorMessageSource
): ResolvedMissingErrorMessage {
switch (source.kind) {
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
}
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
}
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
}
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
}
}
}

View File

@@ -0,0 +1,72 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type { ErrorResolveContext } from './catalogI18n'
import { translateCatalogMessage } from './catalogI18n'
import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
import { st } from '@/i18n'
// Resolves prompt-level errors and non-node-scoped failures before falling
// back to prompt-specific catalog keys.
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error',
'missing_node_type',
'prompt_outputs_failed_validation'
])
function getPromptExceptionMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error']
): string {
const message = error.message.trim()
const prefixedType = `${error.type}: `
return message.startsWith(prefixedType)
? message.slice(prefixedType.length).trim()
: message
}
export function resolvePromptErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
context: ErrorResolveContext
): ResolvedErrorMessage {
const promptExceptionMessage = getPromptExceptionMessage(error)
const runtimeMatch = resolveRuntimeCatalogMatch({
exceptionType: error.type,
exceptionMessage: promptExceptionMessage
})
if (runtimeMatch) {
// Leave toast copy to node-scoped errors where a node-specific
// action/message is safe.
return resolveRuntimeCatalogCopy(
runtimeMatch.catalogId,
promptExceptionMessage || error.message,
context,
{
includeToast: false,
params: runtimeMatch.params,
detailsFallback: runtimeMatch.detailsFallback
}
)
}
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
const errorTypeKey =
error.type === 'server_error'
? context.isCloud
? 'server_error_cloud'
: 'server_error_local'
: error.type
return {
displayTitle: translateCatalogMessage(
`errorCatalog.promptErrors.${errorTypeKey}.title`,
error.message
),
displayMessage: st(
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
error.message
)
}
}

View File

@@ -0,0 +1,53 @@
import type { ResolvedErrorMessage } from './types'
import {
normalizeNodeName,
translateCatalogMessage,
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
// Builds resolved display fields while callers keep the raw API message/details
// on the ErrorItem.
export function resolveRuntimeCatalogCopy(
catalogId: string,
fallbackMessage: string,
context: ErrorResolveContext,
options: {
includeItemLabel?: boolean
includeToast?: boolean
params?: CatalogParams
detailsFallback?: string
} = {}
): ResolvedErrorMessage {
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
const nodeName = normalizeNodeName(context.nodeDisplayName)
const params = { nodeName, ...options.params }
const resolveMessage = (suffix: string, fallback = fallbackMessage) =>
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
const displayMessage = resolveMessage('message')
const result: ResolvedErrorMessage = {
catalogId,
displayTitle: resolveMessage('title'),
displayMessage
}
if (options.includeToast !== false) {
result.toastTitle = resolveMessage('toastTitle')
result.toastMessage = resolveMessage('toastMessage')
}
const displayDetails = translateOptionalCatalogMessage(
`${keyPrefix}.details`,
options.detailsFallback,
params
)
if (displayDetails) result.displayDetails = displayDetails
if (options.includeItemLabel) {
result.displayItemLabel = resolveMessage('itemLabel', nodeName)
}
return result
}

View File

@@ -0,0 +1,398 @@
import {
ACCESS_REQUIRED_CATALOG_ID,
CONTENT_BLOCKED_CATALOG_ID,
GENERATION_STALLED_CATALOG_ID,
IMAGE_NOT_LOADED_CATALOG_ID,
INSUFFICIENT_CREDITS_CATALOG_ID,
INVALID_CLIP_INPUT_CATALOG_ID,
INVALID_PROMPT_CATALOG_ID,
INVALID_WORKFLOW_REQUEST_CATALOG_ID,
MODEL_ACCESS_ERROR_CATALOG_ID,
MODEL_DOWNLOAD_FAILED_CATALOG_ID,
OUT_OF_MEMORY_CATALOG_ID,
PREPROCESSING_FAILED_CATALOG_ID,
PREPROCESSING_TIMEOUT_CATALOG_ID,
RATE_LIMITED_CATALOG_ID,
REQUEST_FAILED_CATALOG_ID,
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
RUN_START_FAILED_CATALOG_ID,
SERVER_BUSY_CATALOG_ID,
SERVER_CRASHED_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
TIMEOUT_CATALOG_ID,
UNEXPECTED_SERVICE_ERROR_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
import type { CatalogParams } from './catalogI18n'
// Runtime errors can share generic exception labels, so targeted cataloging
// relies on narrow stable messages. Keep these matches exact or prefix-based.
const INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your account to use this node.'
])
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your workspace to continue.'
])
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
'Workspace has no active subscription. Please subscribe to a plan to continue.',
'User has no active subscription. Please subscribe to a plan to continue.',
'Subscription required to queue workflows'
])
const SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX =
'the following private models require a subscription upgrade:'
const TIMEOUT_MESSAGES = new Set(['Job execution time exceeded maximum limit'])
const GENERATION_STALLED_MESSAGES = new Set([
'Job went too long without making any progress',
'Job has stagnated'
])
const SERVER_CRASHED_MESSAGES = new Set([
'RIP to the server your workflow was running on.',
'Inference service restarted, terminating job',
'Job stuck in erroring state, forcing terminal transition',
'Job was previously marked as lost and has now been acknowledged by inference service'
])
const SERVER_BUSY_MESSAGES = new Set([
'Failed to enqueue job for processing',
'Executor is busy with another job',
'Servers are busy. Please try again later.'
])
const INVALID_WORKFLOW_REQUEST_MESSAGES = new Set([
'The workflow request is invalid.',
'Invalid job: missing workflow',
"Invalid workflow: missing 'prompt' field",
"Invalid workflow: 'prompt' field must be an object"
])
const ACCESS_REQUIRED_MESSAGE =
'This run requires access that is not available for the current account.'
const MODEL_ACCESS_ERROR_MESSAGE =
'One or more required models could not be accessed.'
const UNEXPECTED_SERVICE_ERROR_MESSAGE = 'Unexpected service error.'
const REQUEST_FAILED_MESSAGE =
'The request failed before the run could complete.'
const RUN_START_FAILED_MESSAGE = 'The run could not be started.'
const RUN_ENDED_UNEXPECTEDLY_MESSAGE = 'The run ended unexpectedly.'
const SIGN_IN_REQUIRED_MESSAGE =
'Unauthorized: Please login first to use this node.'
const RATE_LIMITED_PREFIX = 'Rate Limit Exceeded:'
const CORE_OOM_TIP = 'This error means you ran out of memory on your GPU.'
const CORE_OOM_ALLOCATION_PREFIX = 'Allocation on device'
const CLOUD_OOM_PREFIX =
'Workflow execution failed due to insufficient memory (OOM).'
const ERRNO_DIRECTORY_MESSAGE = '[Errno 21] Is a directory:'
const INVALID_CLIP_INPUT_PREFIX = 'ERROR: clip input is invalid: None'
const PROMPT_TOO_SHORT_MESSAGE =
"Field 'prompt' cannot be shorter than 1 characters; was 0 characters long."
const PROMPT_EMPTY_MESSAGE = "Field 'prompt' cannot be empty."
const PREPROCESSING_FAILED_MESSAGE = 'Preprocessing failed'
const PREPROCESSING_TIMEOUT_MESSAGES = new Set([
'Preprocessing timed out',
'Preprocessing timed out.'
])
const MODEL_DOWNLOAD_PANIC_PREFIX = 'internal error during model download:'
const GENERATED_VIDEO_REJECTED_MESSAGE =
'Generated video rejected by content moderation.'
const GENERATED_CONTENT_REJECTED_MESSAGE =
'Generated content was rejected by a safety check.'
const SAFETY_CHECK_MESSAGE = 'Prompt or Initial Image failed the safety checks.'
const CONTENT_POLICY_VIOLATION_MESSAGE =
'The generated image was flagged for content policy violation.'
const CONTENT_MODERATION_FLAGGED_PREFIX =
'Your request was flagged by our content moderation system'
const GOOGLE_RAI_FILTERED_PREFIX =
"Content filtered by Google's Responsible AI practices"
const GOOGLE_RAI_BLOCKED_PREFIX =
"Content blocked by Google's Responsible AI filters"
const START_FAILED_PREFIXES = [
'Failed to start WebSocket client:',
'Failed to get ComfyUI generation ID:'
]
const REQUEST_FAILED_PREFIXES = ['Failed to send prompt request:']
const SERVER_CRASHED_PREFIXES = [
'Workflow execution was interrupted due to ComfyUI process restart.',
'Job execution interrupted: server shutdown.',
'Failed to clear queue and restart failed:',
'WebSocket failed to reconnect after restart:'
]
const PREPROCESSING_FAILED_PREFIXES = [
'Preprocessing failed:',
'Failed to complete preparation:'
]
export interface RuntimeErrorInfo {
exceptionType: string
exceptionMessage: string
}
interface RuntimeCatalogMatch {
catalogId: string
params?: CatalogParams
detailsFallback?: string
}
interface RuntimeMatchRule {
matches: (info: RuntimeErrorInfo, message: string) => boolean
resolve: (info: RuntimeErrorInfo, message: string) => RuntimeCatalogMatch
}
function catalogMatch(
catalogId: string,
options: Omit<RuntimeCatalogMatch, 'catalogId'> = {}
): RuntimeCatalogMatch {
return { catalogId, ...options }
}
function catalogMatchWithMessageFallback(
catalogId: string,
message: string
): RuntimeCatalogMatch {
return catalogMatch(catalogId, { detailsFallback: message })
}
function isOutOfMemoryError(info: RuntimeErrorInfo): boolean {
const message = info.exceptionMessage
return (
info.exceptionType === 'OOMError' ||
message.includes(CORE_OOM_TIP) ||
message.includes(CORE_OOM_ALLOCATION_PREFIX) ||
message.includes(CLOUD_OOM_PREFIX) ||
message.includes('CUDA out of memory') ||
message.includes('GPU out of memory')
)
}
function isImageNotLoadedError(
info: RuntimeErrorInfo,
message: string
): boolean {
return (
info.exceptionType === 'ImageDownloadError' ||
(info.exceptionType === 'IsADirectoryError' &&
message.includes(ERRNO_DIRECTORY_MESSAGE))
)
}
function getSubscriptionUpgradeDetails(message: string): string {
return message.slice(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX.length).trim()
}
function isContentBlockedError(message: string): boolean {
return (
message.includes(GENERATED_VIDEO_REJECTED_MESSAGE) ||
message.includes(GENERATED_CONTENT_REJECTED_MESSAGE) ||
message.includes(SAFETY_CHECK_MESSAGE) ||
message.includes(CONTENT_POLICY_VIOLATION_MESSAGE) ||
message.startsWith(CONTENT_MODERATION_FLAGGED_PREFIX) ||
message.startsWith(GOOGLE_RAI_FILTERED_PREFIX) ||
message.startsWith(GOOGLE_RAI_BLOCKED_PREFIX)
)
}
function startsWithAny(message: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => message.startsWith(prefix))
}
function hasEmbeddedApiErrorPayload(message: string): boolean {
// Embedded validation responses are parsed by a more specific path, so do not
// catalog them as a generic request failure here.
return /request returned error status \d{3}:\s*\{/.test(message)
}
function isSubscriptionUpgradeMessage(message: string): boolean {
return (
message.toLowerCase().startsWith(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX) &&
getSubscriptionUpgradeDetails(message).length > 0
)
}
// Order matters: the first matching rule wins. Keep narrow user-actionable
// signatures before broader fallbacks.
const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
{
matches: isImageNotLoadedError,
resolve: (_info, message) =>
catalogMatchWithMessageFallback(IMAGE_NOT_LOADED_CATALOG_ID, message)
},
{
matches: isOutOfMemoryError,
resolve: (_info, message) =>
catalogMatchWithMessageFallback(OUT_OF_MEMORY_CATALOG_ID, message)
},
{
matches: (_info, message) => message.startsWith(INVALID_CLIP_INPUT_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(INVALID_CLIP_INPUT_CATALOG_ID, message)
},
{
matches: (_info, message) =>
message.includes(PROMPT_TOO_SHORT_MESSAGE) ||
message.includes(PROMPT_EMPTY_MESSAGE),
resolve: () => catalogMatch(INVALID_PROMPT_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'ValidationError' &&
INVALID_WORKFLOW_REQUEST_MESSAGES.has(message),
resolve: () => catalogMatch(INVALID_WORKFLOW_REQUEST_CATALOG_ID)
},
{
matches: (_info, message) =>
WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'InsufficientFundsError' ||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'InactiveSubscriptionError' ||
SUBSCRIPTION_REQUIRED_MESSAGES.has(message),
resolve: () => catalogMatch(SUBSCRIPTION_REQUIRED_CATALOG_ID)
},
{
matches: (_info, message) => isSubscriptionUpgradeMessage(message),
resolve: (_info, message) => {
const modelNames = getSubscriptionUpgradeDetails(message)
return catalogMatch(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, {
params: { modelNames },
detailsFallback: message
})
}
},
{
matches: (info, message) =>
info.exceptionType === 'AccessRequired' ||
message === ACCESS_REQUIRED_MESSAGE,
resolve: () => catalogMatch(ACCESS_REQUIRED_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'ModelAccessError' ||
message === MODEL_ACCESS_ERROR_MESSAGE,
resolve: () => catalogMatch(MODEL_ACCESS_ERROR_CATALOG_ID)
},
{
matches: (_info, message) => message.includes(SIGN_IN_REQUIRED_MESSAGE),
resolve: () => catalogMatch(SIGN_IN_REQUIRED_CATALOG_ID)
},
{
matches: (_info, message) => message.startsWith(RATE_LIMITED_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(RATE_LIMITED_CATALOG_ID, message)
},
{
matches: (info, message) =>
info.exceptionType === 'PreprocessingTimeout' ||
PREPROCESSING_TIMEOUT_MESSAGES.has(message),
resolve: () => catalogMatch(PREPROCESSING_TIMEOUT_CATALOG_ID)
},
{
matches: (_info, message) =>
message === PREPROCESSING_FAILED_MESSAGE ||
startsWithAny(message, PREPROCESSING_FAILED_PREFIXES),
resolve: (_info, message) =>
message === PREPROCESSING_FAILED_MESSAGE
? catalogMatch(PREPROCESSING_FAILED_CATALOG_ID)
: catalogMatchWithMessageFallback(
PREPROCESSING_FAILED_CATALOG_ID,
message
)
},
{
matches: (info, message) =>
info.exceptionType === 'PanicError' &&
message.startsWith(MODEL_DOWNLOAD_PANIC_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(MODEL_DOWNLOAD_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) => isContentBlockedError(message),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(CONTENT_BLOCKED_CATALOG_ID, message)
},
{
matches: (_info, message) => startsWithAny(message, START_FAILED_PREFIXES),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(RUN_START_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) =>
startsWithAny(message, REQUEST_FAILED_PREFIXES) &&
!hasEmbeddedApiErrorPayload(message),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) => message === RUN_START_FAILED_MESSAGE,
resolve: () => catalogMatch(RUN_START_FAILED_CATALOG_ID)
},
{
matches: (_info, message) => message === RUN_ENDED_UNEXPECTEDLY_MESSAGE,
resolve: () => catalogMatch(RUN_ENDED_UNEXPECTEDLY_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'PanicError' &&
message.startsWith('panic during job execution:'),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
message
)
},
{
matches: (_info, message) => TIMEOUT_MESSAGES.has(message),
resolve: () => catalogMatch(TIMEOUT_CATALOG_ID)
},
{
matches: (_info, message) => GENERATION_STALLED_MESSAGES.has(message),
resolve: () => catalogMatch(GENERATION_STALLED_CATALOG_ID)
},
{
matches: (_info, message) => SERVER_CRASHED_MESSAGES.has(message),
resolve: () => catalogMatch(SERVER_CRASHED_CATALOG_ID)
},
{
matches: (_info, message) =>
startsWithAny(message, SERVER_CRASHED_PREFIXES),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(SERVER_CRASHED_CATALOG_ID, message)
},
{
matches: (_info, message) => SERVER_BUSY_MESSAGES.has(message),
resolve: () => catalogMatch(SERVER_BUSY_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'UnexpectedServiceError' ||
message === UNEXPECTED_SERVICE_ERROR_MESSAGE,
resolve: () => catalogMatch(UNEXPECTED_SERVICE_ERROR_CATALOG_ID)
},
{
matches: (info, message) =>
message === REQUEST_FAILED_MESSAGE ||
(info.exceptionType === 'RequestError' &&
!hasEmbeddedApiErrorPayload(message)),
resolve: (_info, message) =>
message === REQUEST_FAILED_MESSAGE
? catalogMatch(REQUEST_FAILED_CATALOG_ID)
: catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message)
}
]
export function resolveRuntimeCatalogMatch(
info: RuntimeErrorInfo
): RuntimeCatalogMatch | undefined {
const message = info.exceptionMessage.trim()
for (const rule of RUNTIME_MATCH_RULES) {
if (rule.matches(info, message)) return rule.resolve(info, message)
}
return undefined
}

View File

@@ -39,17 +39,16 @@ export type RunErrorMessageSource =
error: NodeValidationError
nodeDisplayName: string
}
| {
kind: 'execution'
error: ExecutionErrorWsMessage
nodeDisplayName?: string
isCloud: boolean
}
| {
kind: 'prompt'
error: PromptError
isCloud: boolean
}
| {
kind: 'execution'
error: ExecutionErrorWsMessage
nodeDisplayName: string
}
export type MissingErrorMessageSource =
| {

View File

@@ -0,0 +1,347 @@
import type { NodeValidationError, ResolvedErrorMessage } from './types'
import {
IMAGE_NOT_LOADED_CATALOG_ID,
MISSING_CONNECTION_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
translateCatalogMessage,
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
// Resolves node validation errors. Most validation types map 1:1 to their
// catalog/locale keys; FE-specific recategorization uses a separate catalogId,
// such as required_input_missing -> missing_connection.
interface ValidationCatalogRule {
catalogId: string
itemLabel: 'node' | 'nodeInput'
copyKeys?: CopyKeys
}
interface CopyKeys {
detailsKey: string
toastMessageKey: string
}
const DEFAULT_COPY_KEYS: CopyKeys = {
detailsKey: 'details',
toastMessageKey: 'toastMessage'
}
const VALUE_SPECIFIC_COPY_RULES: Record<
string,
{
requiredParams: string[]
suffix: 'WithTypes' | 'WithValue'
}
> = {
return_type_mismatch: {
requiredParams: ['expectedType', 'receivedType'],
suffix: 'WithTypes'
},
invalid_input_type: {
requiredParams: ['receivedValue', 'expectedType'],
suffix: 'WithValue'
},
value_smaller_than_min: {
requiredParams: ['receivedValue', 'minValue'],
suffix: 'WithValue'
},
value_bigger_than_max: {
requiredParams: ['receivedValue', 'maxValue'],
suffix: 'WithValue'
},
value_not_in_list: {
requiredParams: ['receivedValue'],
suffix: 'WithValue'
}
}
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
[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'
}
}
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
// dispatch cannot distinguish it. The override also keeps it on default copy
// keys instead of custom_validation_failed's raw-details variant.
const IMAGE_NOT_LOADED_VALIDATION_RULE = {
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
itemLabel: 'node',
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
inputName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
)
}
function getErrorText(error: NodeValidationError) {
return [
'message' in error ? error.message : undefined,
'details' in error ? error.details : undefined
]
.filter(Boolean)
.join('\n')
}
function isImageNotLoadedText(text: string): boolean {
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
}
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
return (
error.type === 'custom_validation_failed' &&
isImageNotLoadedText(getErrorText(error))
)
}
function nodeInputItemLabel(nodeName: string, inputName: string): string {
return `${nodeName} - ${inputName}`
}
function formatDependencyCycleDetails(details: string): string {
// Dependency cycle paths may be reported as "node -> node"; catalog copy
// embeds those paths in prose, where "to" reads more naturally.
return details.replace(/\s*->\s*/g, ' to ')
}
function formatCatalogValue(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
try {
return JSON.stringify(value)
} catch {
return undefined
}
}
function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
const config = inputConfig[1]
if (!config || typeof config !== 'object') return undefined
return formatCatalogValue((config as Record<string, unknown>)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
return formatCatalogValue(inputConfig[0])
}
function getValidationParams(
error: NodeValidationError,
nodeName: string,
inputName: string
): CatalogParams {
const params: CatalogParams = { nodeName, inputName }
const receivedValue = formatCatalogValue(error.extra_info?.received_value)
const receivedType = formatCatalogValue(error.extra_info?.received_type)
const expectedType = getInputConfigType(error)
const minValue = getInputConfigValue(error, 'min')
const maxValue = getInputConfigValue(error, 'max')
if (receivedValue !== undefined) params.receivedValue = receivedValue
if (receivedType !== undefined) params.receivedType = receivedType
if (expectedType !== undefined) params.expectedType = expectedType
if (minValue !== undefined) params.minValue = minValue
if (maxValue !== undefined) params.maxValue = maxValue
return params
}
function hasParams(params: CatalogParams, keys: string[]): boolean {
return keys.every((key) => params[key] !== undefined)
}
function getValueSpecificCopyKeys(
errorType: string,
params: CatalogParams
): CopyKeys {
const rule = VALUE_SPECIFIC_COPY_RULES[errorType]
if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS
return {
detailsKey: `details${rule.suffix}`,
toastMessageKey: `toastMessage${rule.suffix}`
}
}
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
return error.details.trim()
? {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessageWithRawDetails'
}
: DEFAULT_COPY_KEYS
}
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
if (!error.details.trim()) return DEFAULT_COPY_KEYS
return {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessage'
}
}
function getValidationCopyKeys(
error: NodeValidationError,
params: CatalogParams
): CopyKeys {
if (
error.type === 'exception_during_validation' ||
error.type === 'exception_during_inner_validation'
) {
return getRawDetailsCopyKeys(error)
}
if (error.type === 'custom_validation_failed') {
return getRawDetailsOnlyCopyKeys(error)
}
if (error.type === 'dependency_cycle') {
return getRawDetailsOnlyCopyKeys(error)
}
return getValueSpecificCopyKeys(error.type, params)
}
function resolveValidationCatalogCopy(
error: NodeValidationError,
context: ErrorResolveContext,
localeKey: string,
rule: ValidationCatalogRule
): ResolvedErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const trimmedDetails = error.details.trim()
const rawDetails =
error.type === 'dependency_cycle'
? formatDependencyCycleDetails(trimmedDetails)
: trimmedDetails
const params = {
...getValidationParams(error, nodeName, inputName),
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
const titleFallback = error.message || error.type
const itemLabelFallback =
rule.itemLabel === 'node'
? nodeName
: nodeInputItemLabel(nodeName, inputName)
const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params)
return {
catalogId: rule.catalogId,
displayTitle: translateCatalogMessage(
`${keyPrefix}.title`,
titleFallback,
params
),
displayMessage: translateCatalogMessage(
`${keyPrefix}.message`,
error.message,
params
),
displayDetails: translateOptionalCatalogMessage(
`${keyPrefix}.${copyKeys.detailsKey}`,
error.details,
params
),
displayItemLabel: translateCatalogMessage(
`${keyPrefix}.itemLabel`,
itemLabelFallback,
params
),
toastTitle: translateCatalogMessage(
`${keyPrefix}.toastTitle`,
titleFallback,
params
),
toastMessage: translateCatalogMessage(
`${keyPrefix}.${copyKeys.toastMessageKey}`,
error.message,
params
)
}
}
export function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
context,
'image_not_loaded',
IMAGE_NOT_LOADED_VALIDATION_RULE
)
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}

View File

@@ -591,5 +591,31 @@ describe('useWorkflowPersistenceV2', () => {
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when template param is in URL', async () => {
routeMocks.query = { template: 'default-template-id' }
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when template intent is preserved across /user-select redirect', async () => {
preservedQueryMocks.payloads.template = {
template: 'default-template-id'
}
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
})

View File

@@ -161,18 +161,24 @@ export function useWorkflowPersistenceV2() {
})
}
const hasSharedWorkflowIntent = () => {
if (typeof route.query.share === 'string') return true
hydratePreservedQuery(SHARE_NAMESPACE)
const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
return typeof merged?.share === 'string'
const hasPreservedIntent = (namespace: string, key: string) => {
if (typeof route.query[key] === 'string') return true
hydratePreservedQuery(namespace)
const merged = mergePreservedQueryIntoQuery(namespace, route.query)
return typeof merged?.[key] === 'string'
}
const hasSharedWorkflowIntent = () =>
hasPreservedIntent(SHARE_NAMESPACE, 'share')
const hasTemplateUrlIntent = () =>
hasPreservedIntent(TEMPLATE_NAMESPACE, 'template')
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
if (!hasSharedWorkflowIntent()) {
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
} else {

View File

@@ -16,14 +16,6 @@ const mockGetShareableAssets = vi.fn()
const mockFetchApi = vi.fn()
const mockInvalidateInputAssetsIncludingPublic = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/workflow/validation/schemas/workflowSchema',
async (importOriginal) => ({
...(await importOriginal()),
validateComfyWorkflow: vi.fn(async (json: unknown) => json)
})
)
vi.mock('@/scripts/api', () => ({
api: {
getShareableAssets: (...args: unknown[]) => mockGetShareableAssets(...args),
@@ -408,6 +400,36 @@ describe(useWorkflowShareService, () => {
)
})
it('returns raw workflow_json when it does not match ComfyWorkflowJSON schema', async () => {
const rawWorkflowJson = {
extra: {
linearData: {
inputs: [
[1, 'prompt'],
[2, 'seed', 'invalid-third-element']
],
outputs: []
}
}
}
mockFetchApi.mockResolvedValue(
mockJsonResponse({
share_id: 'share-raw',
workflow_id: 'wf-raw',
name: 'Raw',
listed: false,
publish_time: null,
workflow_json: rawWorkflowJson,
assets: []
})
)
const service = useWorkflowShareService()
const shared = await service.getSharedWorkflow('share-raw')
expect(shared.workflowJson).toEqual(rawWorkflowJson)
})
it('treats malformed publish-status payload as unpublished', async () => {
mockFetchApi.mockResolvedValue(mockJsonResponse({ is_published: true }))

View File

@@ -9,7 +9,6 @@ import type {
import { assetService } from '@/platform/assets/services/assetService'
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { AssetInfo } from '@/schemas/apiSchema'
import {
zHubWorkflowPrefillResponse,
@@ -249,12 +248,6 @@ export function useWorkflowShareService() {
throw new Error('Failed to load shared workflow: invalid response')
}
const validated = await validateComfyWorkflow(workflow.workflowJson)
if (!validated) {
throw new Error('Failed to load shared workflow: invalid workflow data')
}
workflow.workflowJson = validated
return workflow
}

View File

@@ -0,0 +1,94 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import WorkspaceSwitcherPopover from './WorkspaceSwitcherPopover.vue'
vi.mock('@/platform/workspace/composables/useWorkspaceSwitch', () => ({
useWorkspaceSwitch: () => ({ switchWorkspace: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ subscription: ref(null) })
}))
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workspaceSwitcher: {
personal: 'Personal',
roleOwner: 'Owner',
roleMember: 'Member',
createWorkspace: 'Create new workspace',
maxWorkspacesReached:
'You can only own 10 workspaces. Delete one to create a new one.'
}
}
}
})
function createWorkspaceState(overrides: Record<string, unknown>) {
return {
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: [],
...overrides
}
}
function renderComponent() {
return render(WorkspaceSwitcherPopover, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
teamWorkspace: {
activeWorkspaceId: 'ws-personal',
isFetchingWorkspaces: false,
workspaces: [
createWorkspaceState({
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}),
createWorkspaceState({
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
role: 'member'
})
]
}
}
}),
i18n
],
stubs: {
WorkspaceProfilePic: true
}
}
})
}
describe('WorkspaceSwitcherPopover', () => {
it('exposes the full team workspace name as a tooltip on the row', () => {
renderComponent()
const name = screen.getByText(LONG_WORKSPACE_NAME)
expect(name).toHaveAttribute('title', LONG_WORKSPACE_NAME)
})
})

Some files were not shown because too many files have changed in this diff Show More