Compare commits

...

15 Commits

Author SHA1 Message Date
Comfy Org PR Bot
2d9b1fed64 [backport core/1.45] fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#12639)
Backport of #11716 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-04 11:30:51 +09:00
Comfy Org PR Bot
deb4045f18 1.45.15 (#12608)
Patch version increment to 1.45.15

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

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-06-02 14:41:53 -07:00
Terry Jia
0b3927d8d5 [backport core/1.45] feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12596)
## Summary
Backport https://github.com/Comfy-Org/ComfyUI_frontend/pull/12545 to
core/1.45

Tested and Verified on local build
<img width="1886" height="1538" alt="image"
src="https://github.com/user-attachments/assets/6f5086e8-05c8-47c8-95cd-8c9bb9ae8a5a"
/>
2026-06-02 13:44:06 -07:00
Comfy Org PR Bot
955472dab5 [backport core/1.45] fix: dedupe Bypass context-menu items via state-aware legacy label (FE-720) (#12586)
Backport of #12500 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-02 14:53:03 +09:00
Comfy Org PR Bot
4ad242181b [backport core/1.45] Track undo state on subgraph conversion (#12584)
Backport of #12575 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-01 18:59:02 -07:00
Comfy Org PR Bot
16dfc33df3 [backport core/1.45] Remove drag node test from interaction.spec.ts (#12588)
Backport of #12579 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-01 18:57:12 -07:00
Comfy Org PR Bot
1a8bf498ef [backport core/1.45] fix: preserve validation errors on execution start (#12547)
Backport of #12493 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:30 +09:00
Comfy Org PR Bot
7b8ad1c11b [backport core/1.45] fix: open model library for desktop model downloads (#12551)
Backport of #12478 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:19 +09:00
Comfy Org PR Bot
364bcb3831 [backport core/1.45] Fix node tooltip metadata i18n parsing (#12555)
Backport of #12469 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:30:43 +09:00
Comfy Org PR Bot
a6699f6922 [backport core/1.45] Fix interrupted audio playback from assets panel (#12524)
Backport of #12425 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 12:39:14 -07:00
Comfy Org PR Bot
962e70d7a5 [backport core/1.45] Fix ghost links on IO remove slot (#12522)
Backport of #12473 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 10:38:39 -07:00
Comfy Org PR Bot
6193b76157 [backport core/1.45] Fix restoring values to dynamic combos (#12489)
Backport of #12211 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 12:00:30 -07:00
Comfy Org PR Bot
c5c916f80e [backport core/1.45] Fix mask editor sometimes showing wrong image (#12483)
Backport of #12413 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:52:46 -07:00
Comfy Org PR Bot
badc97b982 [backport core/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12453)
Backport of #12447 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 22:12:03 -07:00
Comfy Org PR Bot
67affd2075 [backport core/1.45] Fix missing value control on 'Primitive Int' (#12461)
Backport of #12431 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:23:57 -07:00
85 changed files with 3131 additions and 375 deletions

View File

@@ -213,7 +213,8 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
incrementButton: widget.getByTestId(TestIds.widgets.increment)
incrementButton: widget.getByTestId(TestIds.widgets.increment),
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
}
}

View File

@@ -27,6 +27,10 @@ export class ContextMenu {
await this.waitForHidden()
}
menuItem(name: string): Locator {
return this.anyMenu.getByRole('menuitem', { name, exact: true })
}
/**
* Click a litegraph menu entry. Selects the most recently opened matching
* entry so nested submenu items can be reached without being shadowed by

View File

@@ -11,6 +11,11 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
type RunOptions = {
nodeErrors?: Record<string, NodeError>
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
}
/**
* Build a `NodeError` describing a single failed input on a KSampler node.
* Shared between specs that surface validation rings via 400 responses.
@@ -70,8 +75,9 @@ export class ExecutionHelper {
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(): Promise<string> {
async run(options: RunOptions = {}): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
const { nodeErrors = {}, onPromptRequest } = options
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
@@ -81,12 +87,13 @@ export class ExecutionHelper {
await this.page.route(
PROMPT_ROUTE_PATTERN,
async (route) => {
await onPromptRequest?.(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: {}
node_errors: nodeErrors
})
})
fulfilled()

View File

@@ -135,7 +135,8 @@ export const TestIds = {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button'
},
menu: {
moreMenuContent: 'more-menu-content'
@@ -152,6 +153,7 @@ export const TestIds = {
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
valueControl: 'value-control',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button',
selectDefaultSearchInput: 'widget-select-default-search-input',

View File

@@ -4,6 +4,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type ChangeTrackerDebugState = {
changeCount: number
@@ -310,4 +311,28 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
]
})
})
test(
'Tracks convert to subgraph as undo step',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
await input.fill('40')
await node.title.click()
await comfyPage.page
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
.click()
await expect(input).toBeHidden()
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('40')
await comfyPage.keyboard.undo()
await expect(input).toHaveValue('512')
}
)
})

View File

@@ -1,7 +1,60 @@
import { expect } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { NodeError } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const VALIDATION_ERROR_NODE_ID = '1'
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
type PromptRequestNode = {
class_type?: string
}
type PromptRequestBody = {
prompt?: Record<string, PromptRequestNode>
}
function buildPreviewAnyValidationError(): NodeError {
return {
class_type: 'PreviewAny',
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
errors: [
{
type: 'required_input_missing',
message: VALIDATION_ERROR_MESSAGE,
details: '',
extra_info: { input_name: 'source' }
}
]
}
}
function expectPartialExecutionRootNodes(requestBody: unknown): void {
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
}
}
async function getValidationErrorMessage(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate(
(nodeId) =>
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
?.message ?? null,
VALIDATION_ERROR_NODE_ID
)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -74,3 +127,48 @@ test.describe(
})
}
)
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
test('preserves validation errors when another active root starts execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
const nodeErrors = {
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
}
let promptRequestBody: unknown
const jobId = await exec.run({
nodeErrors,
onPromptRequest: (requestBody) => {
promptRequestBody = requestBody
}
})
expectPartialExecutionRootNodes(promptRequestBody)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await comfyPage.nextFrame()
exec.executionStart(jobId)
await expect
.poll(() => getValidationErrorMessage(comfyPage))
.toBe(VALIDATION_ERROR_MESSAGE)
await expect(errorOverlay).toBeVisible()
})
})

View File

@@ -166,15 +166,6 @@ test.describe('Node Interaction', () => {
})
})
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
maxDiffPixels: 50
})
})
test.describe('Node Duplication', () => {
test.beforeEach(async ({ comfyPage }) => {
// Pin this suite to the legacy canvas path so Alt+drag exercises

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1,6 +1,10 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
test(
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
)
})
wstest(
'Will not use stale litegraph previews',
async ({ comfyPage, getWebSocket }) => {
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.searchBoxV2.addNode('Preview Image')
async function getNodeOutput() {
return await comfyPage.page.evaluate(
() => graph!.getNodeById('1')!.images?.[0]?.filename
)
}
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
await expect.poll(getNodeOutput).toBe('test1.png')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const resolvableFile = { filename: 'example.png', type: 'input' }
executionHelper.executed('', '1', { images: [resolvableFile] })
await expect.poll(getNodeOutput).toBe('example.png')
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await node.imagePreview.hover()
await node.imagePreview
.getByRole('button', { name: 'Edit or mask image' })
.click()
// On previous versions, attempting to open the mask editor here would
// incorrectly reference the non-existant test1.png
// This causes the mask editor to throw in setup and not display
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
}
)

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
/**
* Expanded folder view must drop output records that resolve to the same
* composite `${nodeId}-${subfolder}-${filename}` key; otherwise Vue's keyed
* v-for in VirtualGrid collides and one asset visibly duplicates its
* neighbours while scrolling.
*/
const STACK_JOB_ID = 'job-output-dedupe'
const COVER_NODE_ID = '9'
const COVER_FILENAME = 'cover_00001_.png'
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']
// 5 records: 1 cover + 2 distinct + 2 sharing DUPLICATE_FILENAME.
// 4 unique composite keys expected after dedupe.
const STACK_JOB_OUTPUTS = [
{ filename: COVER_FILENAME, subfolder: '', type: 'output' as const },
...DISTINCT_FILENAMES.map((filename) => ({
filename,
subfolder: '',
type: 'output' as const
})),
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const },
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const }
]
const STACK_JOB = createMockJob({
id: STACK_JOB_ID,
create_time: 5000,
execution_start_time: 5000,
execution_end_time: 5050,
preview_output: {
filename: COVER_FILENAME,
subfolder: '',
type: 'output',
nodeId: COVER_NODE_ID,
mediaType: 'images'
},
outputs_count: STACK_JOB_OUTPUTS.length
})
const STACK_JOB_DETAIL: JobDetail = {
...STACK_JOB,
outputs: {
[COVER_NODE_ID]: { images: STACK_JOB_OUTPUTS }
}
}
const EXPECTED_TOTAL_TILES = 4
test.describe(
'Expanded folder view dedupes duplicate composite output keys',
{ tag: '@cloud' },
() => {
// @cloud comfyPage already navigates with Firebase auth seeded; a second
// setup() call would clear localStorage and bounce to /cloud/login.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([STACK_JOB])
await comfyPage.assets.mockInputFiles([])
await comfyPage.assets.mockJobDetail(STACK_JOB_ID, STACK_JOB_DETAIL)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('renders one tile per unique composite key', async ({
comfyPage
}, testInfo) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards
.first()
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.assetCards).toHaveCount(EXPECTED_TOTAL_TILES)
const labels = await tab.assetCards.evaluateAll((nodes) =>
nodes
.map((el) => el.getAttribute('aria-label'))
.filter((v): v is string => v !== null)
)
expect(new Set(labels).size).toBe(labels.length)
await testInfo.attach('expanded-folder-view.png', {
body: await comfyPage.page.screenshot({ fullPage: false }),
contentType: 'image/png'
})
})
}
)

View File

@@ -1,8 +1,7 @@
import { expect } from '@playwright/test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -651,6 +650,12 @@ test(
await expect.poll(isConnected).toBe(true)
})
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
@@ -659,9 +664,6 @@ test(
await comfyPage.page.mouse.down()
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
clip
})
@@ -699,5 +701,18 @@ test(
'opacity',
'0'
)
await test.step('Can disconnect link by right click', async () => {
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
const { x, y } = await stepsIOSlot.getPosition()
await comfyPage.page.mouse.click(x, y, { button: 'right' })
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await expect(slotParent).toHaveCSS('opacity', '0')
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const postScreenshot = await comfyPage.page.screenshot({ clip })
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
})
}
)

View File

@@ -166,7 +166,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(nodeRef).toBeBypassed()
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
@@ -174,12 +174,33 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(nodeRef).not.toBeBypassed()
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
})
test('shows exactly one bypass menu item per state (FE-720 regression)', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
const bypassItem = comfyPage.contextMenu.menuItem('Bypass')
const removeBypassItem = comfyPage.contextMenu.menuItem('Remove Bypass')
await openContextMenu(comfyPage, nodeTitle)
await expect(bypassItem).toHaveCount(1)
await expect(removeBypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Bypass')
await expect(nodeRef).toBeBypassed()
await openContextMenu(comfyPage, nodeTitle)
await expect(removeBypassItem).toHaveCount(1)
await expect(bypassItem).toHaveCount(0)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
await expect(nodeRef).not.toBeBypassed()
})
test('should minimize and expand node via context menu', async ({
comfyPage
}) => {
@@ -451,7 +472,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(nodeRef).toBeBypassed()
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
@@ -460,7 +481,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(nodeRef).not.toBeBypassed()
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)

View File

@@ -38,4 +38,15 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
})
test('displays control widgets with default state', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Int')
const widget = comfyPage.vueNodes.getWidgetByName('Int', 'value')
await expect(widget).toBeVisible()
const { valueControl } = comfyPage.vueNodes.getInputNumberControls(widget)
await expect(valueControl).toBeVisible()
})
})

View File

@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.push(node.widgets![0])
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets![2] = node.widgets![0]
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.widgets!.splice(0, 0, node.widgets![0])
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
})
await expect(loadCheckpointNode).toHaveCount(4)
})
@@ -52,4 +55,24 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
})
await expect(loadCheckpointNode).toHaveCount(3)
})
test('Can load dynamic combos', async ({ comfyPage }) => {
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
await test.step('Update value of the dynamic combo widget', async () => {
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
await expect(widget).toHaveText('scale width')
})
await test.step('Swap to a different workflow and back', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await expect(widget).toBeHidden()
await comfyPage.menu.topbar.getTab(0).click()
await expect(widget).toBeVisible()
})
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
})

View File

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

View File

@@ -159,7 +159,7 @@
<audio
:ref="(el) => (audioRef = el as HTMLAudioElement)"
:src="audioSrc"
:src
preload="metadata"
class="hidden"
/>
@@ -192,7 +192,6 @@ const progressRef = ref<HTMLElement>()
const {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying,

View File

@@ -0,0 +1,222 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n, te } from '@/i18n'
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import NodeTooltip from './NodeTooltip.vue'
type HitTest = (
node: MockNode,
x: number,
y: number,
offset: [number, number]
) => number
interface MockWidget {
name: string
tooltip?: string
}
interface MockNode {
type: string
flags: {
collapsed?: boolean
ghost?: boolean
}
pos: [number, number]
inputs: Array<{ name: string }>
constructor: {
title_mode?: 0 | 1 | 2 | 3
}
}
interface MockCanvas {
mouse: [number, number]
graph_mouse: [number, number]
node_over: MockNode | null
getWidgetAtCursor: () => MockWidget | null
}
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
const mockIsDOMWidget = vi.hoisted(() =>
vi.fn<(widget: MockWidget) => boolean>()
)
const mockCanvas = vi.hoisted(
(): MockCanvas => ({
mouse: [100, 80],
graph_mouse: [10, 10],
node_over: null,
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
})
)
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<typeof LiteGraphModule>()
return {
...actual,
isOverNodeInput: mockIsOverNodeInput,
isOverNodeOutput: mockIsOverNodeOutput
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: mockCanvas
}
}))
vi.mock('@/scripts/domWidget', () => ({
isDOMWidget: mockIsDOMWidget
}))
const jsonTooltip =
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
const positiveCoordsTooltipKey =
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const sam3DetectNodeDef: ComfyNodeDef = {
name: 'SAM3_Detect',
display_name: 'SAM3 Detect',
category: 'detection/',
python_module: 'comfy_extras.nodes_sam3',
description: '',
input: {
required: {},
optional: {
positive_coords: [
'STRING',
{
tooltip: jsonTooltip,
forceInput: true
}
]
}
},
output: ['MASK'],
output_name: ['masks'],
output_tooltips: [jsonTooltip],
output_node: false,
deprecated: false,
experimental: false
}
function createSam3Node(): MockNode {
return {
type: 'SAM3_Detect',
flags: {},
pos: [0, 0],
inputs: [{ name: 'positive_coords' }],
constructor: {}
}
}
function mergeOutputTooltipMessage(tooltip: string | null) {
i18n.global.mergeLocaleMessage('en', {
nodeDefs: {
SAM3_Detect: {
outputs: {
0: {
tooltip
}
}
}
}
})
}
async function renderAndHoverCanvas() {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(NodeTooltip)
const canvas = document.createElement('canvas')
document.body.appendChild(canvas)
await user.hover(canvas)
await vi.runOnlyPendingTimersAsync()
await nextTick()
}
describe('NodeTooltip', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(useSettingStore(), 'get').mockImplementation(
<K extends keyof Settings>(key: K): Settings[K] => {
switch (key) {
case 'LiteGraph.Node.TooltipDelay':
return 0 as Settings[K]
default:
return undefined as Settings[K]
}
}
)
mockCanvas.mouse = [100, 80]
mockCanvas.graph_mouse = [10, 10]
mockCanvas.node_over = createSam3Node()
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
mergeOutputTooltipMessage(jsonTooltip)
})
afterEach(() => {
mergeOutputTooltipMessage(null)
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
await renderAndHoverCanvas()
expect(te(outputTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
name: 'positive_coords'
})
await renderAndHoverCanvas()
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
expect(consoleError).not.toHaveBeenCalled()
})
})

View File

@@ -13,7 +13,7 @@
import { useEventListener } from '@vueuse/core'
import { nextTick, ref } from 'vue'
import { st } from '@/i18n'
import { stRaw } from '@/i18n'
import {
LiteGraph,
isOverNodeInput,
@@ -84,7 +84,7 @@ function onIdle() {
)
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef?.inputs[inputName]?.tooltip ?? ''
)
@@ -98,7 +98,7 @@ function onIdle() {
[0, 0]
)
if (outputSlot !== -1) {
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
)
@@ -108,7 +108,7 @@ function onIdle() {
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
const translatedTooltip = stRaw(
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef?.inputs[widget.name]?.tooltip ?? ''
)

View File

@@ -44,11 +44,14 @@
/>
</div>
<div
v-if="canFitToViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
>
<div class="flex flex-col rounded-lg bg-backdrop/30">
<div
v-if="canFitToViewer || canCenterCameraOnModel"
class="flex flex-col rounded-lg bg-backdrop/30"
>
<Button
v-if="canFitToViewer"
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
@@ -61,25 +64,29 @@
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
<Button
v-if="canCenterCameraOnModel"
v-tooltip.left="{
value: $t('load3d.centerCameraOnModel'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.centerCameraOnModel')"
@click="handleCenterCameraOnModel"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
</div>
</div>
<div
v-if="enable3DViewer && node"
class="pointer-events-auto absolute top-24 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
</div>
<ViewerControls
v-if="enable3DViewer && node"
:node="node as LGraphNode"
/>
<div
v-if="!isPreview"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-24': !enable3DViewer,
'top-36': enable3DViewer
}"
>
<RecordingControls
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -142,6 +149,7 @@ const {
isRecording,
isPreview,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -175,6 +183,7 @@ const {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -71,6 +71,7 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -11,17 +11,39 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
<i class="pi pi-camera text-lg text-base-foreground" />
</Button>
<PopupSlider
v-if="showFOVButton"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<Button
v-tooltip.right="{
value: $t('load3d.retainViewOnReload'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.retainViewOnReload')"
:aria-pressed="retainViewOnReload"
@click="retainViewOnReload = !retainViewOnReload"
>
<i
:class="
cn(
'pi text-lg text-base-foreground',
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
)
"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
default: false
})
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {

View File

@@ -258,6 +258,34 @@ describe('useSelectedLiteGraphItems', () => {
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
})
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
app.canvas.selected_nodes = { '0': node1, '1': node2 }
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
})
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
app.canvas.selected_nodes = { '0': bypassed, '1': active }
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('areAllSelectedNodesInMode returns false for empty selection', () => {
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
app.canvas.selected_nodes = {}
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
})
it('getSelectedNodes should include nodes from subgraphs', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode

View File

@@ -93,6 +93,22 @@ export function useSelectedLiteGraphItems() {
return collectFromNodes(nodeArray)
}
const getSelectedNodesShallow = (): LGraphNode[] =>
Object.values(app.canvas.selected_nodes ?? {})
/**
* True iff every selected node is in `mode`. Mirrors the predicate used by
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
* An empty selection returns `false` (no node is in the mode).
*/
const areAllSelectedNodesInMode = (mode: LGraphEventMode): boolean => {
const selectedNodeArray = getSelectedNodesShallow()
return (
selectedNodeArray.length > 0 &&
selectedNodeArray.every((node) => node.mode === mode)
)
}
/**
* Toggle the execution mode of all selected nodes
*
@@ -102,18 +118,10 @@ export function useSelectedLiteGraphItems() {
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
*/
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes) return
// Convert selected_nodes object to array
const selectedNodeArray: LGraphNode[] = []
for (const i in selectedNodes) {
selectedNodeArray.push(selectedNodes[i])
}
const allNodesMatch = !selectedNodeArray.some(
(selectedNode) => selectedNode.mode !== mode
)
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
const selectedNodeArray = getSelectedNodesShallow()
const newModeForSelectedNode = areAllSelectedNodesInMode(mode)
? LGraphEventMode.ALWAYS
: mode
for (const selectedNode of selectedNodeArray)
selectedNode.mode = newModeForSelectedNode
@@ -126,6 +134,7 @@ export function useSelectedLiteGraphItems() {
hasSelectableItems,
hasMultipleSelectableItems,
getSelectedNodes,
areAllSelectedNodesInMode,
toggleSelectedNodesMode
}
}

View File

@@ -135,6 +135,51 @@ describe('contextMenuConverter', () => {
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('blacklists the legacy Bypass push so Vue supplies the only item', () => {
const legacyOptions = convertContextMenuToOptions(
[{ content: 'Bypass', callback: () => {} }],
undefined,
false
)
expect(
legacyOptions.find(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
).toBeUndefined()
const vueBypass: MenuOption = {
label: 'Remove Bypass',
icon: 'icon-[lucide--redo-dot]',
shortcut: 'Ctrl+B',
action: () => {},
source: 'vue'
}
const result = buildStructuredMenu([...legacyOptions, vueBypass])
const bypassItems = result.filter(
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
)
expect(bypassItems).toHaveLength(1)
expect(bypassItems[0].source).toBe('vue')
expect(bypassItems[0].shortcut).toBe('Ctrl+B')
})
it('does not treat Bypass and Remove Bypass as label equivalents', () => {
const options: MenuOption[] = [
{ label: 'Bypass', action: () => {}, source: 'vue' },
{ label: 'Remove Bypass', action: () => {}, source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const labels = result
.map((opt) => opt.label)
.filter((l) => l === 'Bypass' || l === 'Remove Bypass')
expect(labels).toEqual(
expect.arrayContaining(['Bypass', 'Remove Bypass'])
)
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },

View File

@@ -21,7 +21,10 @@ const HARD_BLACKLIST = new Set([
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)'
'Copy (Clipspace)',
// Vue getBypassOption supplies the single state-aware Bypass/Remove Bypass item
'Bypass',
'Remove Bypass'
])
/**

View File

@@ -23,6 +23,7 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
@@ -154,9 +155,7 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),

View File

@@ -212,7 +212,7 @@ export function useMoreOptionsMenu() {
}
if (!groupContext) {
const pin = getPinOption(states, bump)
const bypass = getBypassOption(states, bump)
const bypass = getBypassOption(bump)
options.push(pin)
options.push(bypass)
}

View File

@@ -0,0 +1,97 @@
import { render } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
const mockApp = vi.hoisted(() => ({
canvas: {
selected_nodes: null as Record<string, LGraphNode> | null
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
const setSelectedNodes = (nodes: LGraphNode[]) => {
const dict: Record<string, LGraphNode> = {}
nodes.forEach((n, i) => {
dict[String(i)] = n
})
mockApp.canvas.selected_nodes = dict
}
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode =>
({ id, mode }) as LGraphNode
const getBypassLabel = (): string => {
let label = ''
const Wrapper = defineComponent({
setup() {
const { getBypassOption } = useNodeMenuOptions()
label = getBypassOption(() => {}).label ?? ''
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
return label
}
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockApp.canvas.selected_nodes = null
})
it('labels as "Bypass" when no node is bypassed', () => {
setSelectedNodes([nodeWithMode(LGraphEventMode.ALWAYS, 1)])
expect(getBypassLabel()).toBe('contextMenu.Bypass')
})
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
setSelectedNodes([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.BYPASS, 2)
])
expect(getBypassLabel()).toBe('contextMenu.Remove Bypass')
})
it('labels as "Bypass" on mixed selection so it matches the toggle action', () => {
setSelectedNodes([
nodeWithMode(LGraphEventMode.BYPASS, 1),
nodeWithMode(LGraphEventMode.ALWAYS, 2)
])
expect(getBypassLabel()).toBe('contextMenu.Bypass')
})
})

View File

@@ -1,6 +1,9 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
import { useSelectedNodeActions } from './useSelectedNodeActions'
@@ -20,6 +23,7 @@ export function useNodeMenuOptions() {
toggleNodeBypass,
runBranch
} = useSelectedNodeActions()
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
const shapeSubmenu = computed(() =>
shapeOptions.map((shape) => ({
@@ -91,11 +95,8 @@ export function useNodeMenuOptions() {
}
})
const getBypassOption = (
states: NodeSelectionState,
bump: () => void
): MenuOption => ({
label: states.bypassed
const getBypassOption = (bump: () => void): MenuOption => ({
label: areAllSelectedNodesInMode(LGraphEventMode.BYPASS)
? t('contextMenu.Remove Bypass')
: t('contextMenu.Bypass'),
icon: 'icon-[lucide--redo-dot]',

View File

@@ -2,7 +2,7 @@ import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -13,7 +13,6 @@ import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
collapsed: boolean
pinned: boolean
bypassed: boolean
}
/**
@@ -78,12 +77,10 @@ export function useSelectionState() {
const computeSelectionStatesFromNodes = (
nodes: LGraphNode[]
): NodeSelectionState => {
if (!nodes.length)
return { collapsed: false, pinned: false, bypassed: false }
if (!nodes.length) return { collapsed: false, pinned: false }
return {
collapsed: nodes.some((n) => n.flags?.collapsed),
pinned: nodes.some((n) => n.pinned),
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
pinned: nodes.some((n) => n.pinned)
}
}

View File

@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setRetainViewOnReload: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
@@ -191,6 +192,7 @@ describe('useLoad3d', () => {
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}),
getModelInfo: vi.fn().mockReturnValue(null),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
@@ -568,17 +570,21 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
vi.mocked(mockLoad3d.setFOV!).mockClear()
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
composable.cameraConfig.value.retainViewOnReload = true
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null
state: null,
retainViewOnReload: true
})
})
@@ -1349,6 +1355,39 @@ describe('useLoad3d', () => {
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('gizmoTransformChange mirrors the live scene into Scene Config models', async () => {
const modelTransform = {
position: { x: 5, y: 6, z: 7 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 3, y: 3, z: 3 }
}
vi.mocked(mockLoad3d.getModelInfo!).mockReturnValue(modelTransform)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const handler = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)![1] as (data: unknown) => void
handler({
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7 },
scale: { x: 3, y: 3, z: 3 },
enabled: true,
mode: 'rotate'
})
await nextTick()
expect(composable.sceneConfig.value.models).toEqual([modelTransform])
const savedScene = mockNode.properties['Scene Config'] as {
models: unknown[]
}
expect(savedScene.models).toEqual([modelTransform])
})
it('should reset gizmo config on model switch (not first load)', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')

View File

@@ -132,6 +132,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const canFitToViewer = ref(true)
const canCenterCameraOnModel = ref(false)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
const canExport = ref(true)
@@ -483,6 +484,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
}
},
{ deep: true }
@@ -788,6 +790,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const syncSceneModels = () => {
const modelInfo = load3d?.getModelInfo()
sceneConfig.value.models = modelInfo ? [modelInfo] : []
}
const eventConfig = {
materialModeChange: (value: string) => {
modelConfig.value.materialMode = value as MaterialMode
@@ -847,6 +854,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
canUseGizmo.value = caps?.gizmoTransform ?? true
@@ -859,6 +867,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
]
hasSkeleton.value = load3d?.hasSkeleton() ?? false
applyGizmoConfigToLoad3d()
syncSceneModels()
isFirstModelLoad = false
},
modelReady: () => {
@@ -935,6 +944,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelConfig.value.gizmo.enabled = data.enabled
modelConfig.value.gizmo.mode = data.mode
}
syncSceneModels()
}
} as const
@@ -960,6 +970,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const transform = load3d.getGizmoTransform()
modelConfig.value.gizmo.position = transform.position
modelConfig.value.gizmo.scale = transform.scale
syncSceneModels()
}
const handleCenterCameraOnModel = () => {
load3d?.centerCameraOnModel()
}
const handleResetGizmoTransform = () => {
@@ -1002,6 +1017,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isSplatModel,
isPlyModel,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -1037,6 +1053,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
}
}

View File

@@ -4,6 +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 type {
AnimationItem,
BackgroundRenderModeType,
@@ -368,7 +369,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = node.type === 'Preview3D'
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -108,23 +108,6 @@ describe('useWaveAudioPlayer', () => {
expect(bars.value).toHaveLength(10)
})
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
mockFetchApi.mockRejectedValue(new Error('Network error'))
const src = ref('/api/view?filename=audio.wav&type=output')
const { bars, loading, audioSrc } = useWaveAudioPlayer({
src,
barCount: 10
})
await vi.waitFor(() => {
expect(loading.value).toBe(false)
})
expect(bars.value).toHaveLength(10)
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
})
it('does not call decodeAudioSource when src is empty', () => {
const src = ref('')
useWaveAudioPlayer({ src })

View File

@@ -1,5 +1,5 @@
import { useMediaControls, whenever } from '@vueuse/core'
import { computed, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { api } from '@/scripts/api'
@@ -19,7 +19,6 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
const audioRef = ref<HTMLAudioElement>()
const waveformRef = ref<HTMLElement>()
const blobUrl = ref<string>()
const loading = ref(false)
let decodeRequestId = 0
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
@@ -35,10 +34,6 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
const formattedDuration = computed(() => formatTime(duration.value))
const audioSrc = computed(() =>
src.value ? (blobUrl.value ?? src.value) : ''
)
function generatePlaceholderBars(): WaveformBar[] {
return Array.from({ length: barCount }, () => ({
height: Math.random() * 60 + 10
@@ -90,22 +85,12 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
if (requestId !== decodeRequestId) return
const blob = new Blob([arrayBuffer.slice(0)], {
type: response.headers.get('content-type') ?? 'audio/wav'
})
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
blobUrl.value = URL.createObjectURL(blob)
ctx = new AudioContext()
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
if (requestId !== decodeRequestId) return
generateBarsFromBuffer(audioBuffer)
} catch {
if (requestId === decodeRequestId) {
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = undefined
}
bars.value = generatePlaceholderBars()
}
} finally {
@@ -173,19 +158,9 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
{ immediate: true }
)
onUnmounted(() => {
decodeRequestId += 1
audioRef.value?.pause()
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = undefined
}
})
return {
audioRef,
waveformRef,
audioSrc,
bars,
loading,
isPlaying: playing,

View File

@@ -11,6 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -22,6 +23,7 @@ import {
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const INLINE_INPUTS = false
@@ -185,11 +187,18 @@ function dynamicComboWidget(
//A little hacky, but onConfigure won't work.
//It fires too late and is overly disruptive
let widgetValue = widget.value
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
}
Object.defineProperty(widget, 'value', {
get() {
return widgetValue
return getState()?.value ?? widgetValue
},
set(value) {
const state = getState()
if (state) state.value = value
widgetValue = value
updateWidgets(value)
}

View File

@@ -164,6 +164,7 @@ function makeLoad3DNode(
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
size: [300, 600],
setSize: vi.fn(),
addWidget: vi.fn(),
widgets: overrides.widgets ?? [
{ name: 'model_file', value: '' },
{ name: 'width', value: 512 },

View File

@@ -6,7 +6,8 @@ import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
@@ -218,13 +219,15 @@ useExtensionService().registerExtension({
},
{
id: 'Comfy.Load3D.PLYEngine',
category: ['3D', 'PLY', 'PLY Engine'],
name: 'PLY Engine',
category: ['3D', 'PointCloud', 'Point Cloud Engine'],
name: 'Point Cloud Engine',
tooltip:
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
'Select the engine for loading point cloud PLY files. "threejs" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). "fastply" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.',
type: 'combo',
options: ['threejs', 'fastply', 'sparkjs'],
options: ['threejs', 'fastply'],
defaultValue: 'threejs',
migrateDeprecatedValue: (value) =>
value === 'sparkjs' ? 'threejs' : value,
experimental: true
}
],
@@ -266,40 +269,44 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
if (node.constructor.comfyClass === 'Load3D') {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
node.properties['Resource Folder'] = ''
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
})
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_file'
)
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
})
}
const widget = new ComponentWidgetImpl({
node: node,
@@ -402,6 +409,9 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
@@ -409,7 +419,8 @@ useExtensionService().registerExtension({
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: ''
recording: '',
model_3d_info
}
const recordingData = currentLoad3d.getRecordingData()

View File

@@ -162,6 +162,42 @@ describe('CameraManager', () => {
const snapshot = manager.getCameraState()
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
})
it('captures the active camera orientation as a serializable quaternion', () => {
manager.perspectiveCamera.position.set(5, 0, 0)
manager.perspectiveCamera.lookAt(0, 0, 0)
const { quaternion } = manager.getCameraState()
expect(quaternion).toEqual({
x: manager.perspectiveCamera.quaternion.x,
y: manager.perspectiveCamera.quaternion.y,
z: manager.perspectiveCamera.quaternion.z,
w: manager.perspectiveCamera.quaternion.w
})
expect(Object.keys(quaternion ?? {})).not.toContain('_x')
})
it('captures the configured perspective fov regardless of active camera', () => {
manager.perspectiveCamera.fov = 42
manager.toggleCamera('orthographic')
expect(manager.getCameraState().fov).toBe(42)
})
it('reflects the perspective aspect after a resize', () => {
manager.handleResize(800, 400)
expect(manager.getCameraState().aspect).toBe(2)
})
it('reflects the orthographic frustum bounds after a resize', () => {
manager.toggleCamera('orthographic')
manager.handleResize(800, 400)
const { frustum } = manager.getCameraState()
expect(frustum).toEqual({ left: -10, right: 10, top: 5, bottom: -5 })
})
})
describe('setControls', () => {

View File

@@ -144,6 +144,10 @@ export class CameraManager implements CameraManagerInterface {
}
getCameraState(): CameraState {
const { x, y, z, w } = this.activeCamera.quaternion
const activeCamera = this.activeCamera as
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
return {
position: this.activeCamera.position.clone(),
target: this.controls?.target.clone() || new THREE.Vector3(),
@@ -151,7 +155,18 @@ export class CameraManager implements CameraManagerInterface {
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: this.getCurrentCameraType()
cameraType: this.getCurrentCameraType(),
quaternion: { x, y, z, w },
fov: this.perspectiveCamera.fov,
aspect: this.perspectiveCamera.aspect,
near: activeCamera.near,
far: activeCamera.far,
frustum: {
left: this.orthographicCamera.left,
right: this.orthographicCamera.right,
top: this.orthographicCamera.top,
bottom: this.orthographicCamera.bottom
}
}
}

View File

@@ -314,6 +314,30 @@ describe('GizmoManager', () => {
})
})
describe('getModelInfo', () => {
it('returns the full transform payload for the target object', () => {
manager.init()
const model = new THREE.Object3D()
model.name = 'my-model'
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const info = manager.getModelInfo()
expect(info).not.toBeNull()
expect(info!.position).toEqual({ x: 1, y: 2, z: 3 })
expect(info!.quaternion.w).toBeCloseTo(model.quaternion.w)
expect(info!.scale).toEqual({ x: 4, y: 5, z: 6 })
})
it('returns null when there is no target', () => {
manager.init()
expect(manager.getModelInfo()).toBeNull()
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()

View File

@@ -3,7 +3,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode } from './interfaces'
import type { GizmoMode, Model3DTransform } from './interfaces'
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -215,6 +215,30 @@ export class GizmoManager {
}
}
getModelInfo(): Model3DTransform | null {
const object = this.targetObject
if (!object) return null
return {
position: {
x: object.position.x,
y: object.position.y,
z: object.position.z
},
quaternion: {
x: object.quaternion.x,
y: object.quaternion.y,
z: object.quaternion.z,
w: object.quaternion.w
},
scale: {
x: object.scale.x,
y: object.scale.y,
z: object.scale.z
}
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()

View File

@@ -2,7 +2,10 @@ import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Load3d from '@/extensions/core/load3d/Load3d'
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
import type {
CameraState,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
const {
cloneSkinnedMock,
@@ -769,6 +772,133 @@ describe('Load3d', () => {
})
})
describe('retainViewOnReload', () => {
function setupLoadInternal(initialFlag: boolean) {
const getCameraState = vi.fn<() => CameraState>(() => ({
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'perspective'
}))
const setCameraState = vi.fn()
const getCurrentCameraType = vi.fn(() => 'perspective' as const)
const loaderLoadModel = vi.fn().mockResolvedValue(undefined)
Object.assign(ctx.load3d, {
cameraManager: {
...ctx.cameraManager,
getCameraState,
setCameraState,
getCurrentCameraType
},
controlsManager: { ...ctx.controlsManager, reset: vi.fn() },
loaderManager: { loadModel: loaderLoadModel },
modelManager: {
...ctx.modelManager,
currentModel: new THREE.Group(),
originalModel: null
},
animationManager: {
...ctx.animationManager,
setupModelAnimations: vi.fn()
},
handleResize: vi.fn(),
retainViewOnReload: initialFlag,
hasLoadedModel: false
})
return { getCameraState, setCameraState, getCurrentCameraType }
}
it('first load uses default framing even with retain enabled', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
// hasLoadedModel started false, so retain shouldn't kick in yet.
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('subsequent load captures camera state, skips reset, and restores it', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('does not retain when the flag is off, even after a prior load', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('toggles to the saved camera type before restoring state when types differ', async () => {
const mocks = setupLoadInternal(true)
mocks.getCameraState.mockImplementation(() => ({
position: new THREE.Vector3(0, 0, 5),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'orthographic'
}))
// First load (active type stays perspective per the default mock).
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.toggleCamera as ReturnType<typeof vi.fn>).mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.clearModel()
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
})
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.setRetainViewOnReload(true)
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }

View File

@@ -25,6 +25,7 @@ import type {
Load3DOptions,
LoadModelOptions,
MaterialMode,
Model3DTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
@@ -104,6 +105,8 @@ class Load3d {
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private retainViewOnReload: boolean = false
private hasLoadedModel: boolean = false
constructor(
container: Element | HTMLElement,
@@ -158,11 +161,23 @@ class Load3d {
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
}
private async repaintWhenSparkPaintable(): Promise<void> {
const sortComplete = this.sceneManager.awaitNextSparkDirty()
this.forceRender()
await sortComplete
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
@@ -564,13 +579,25 @@ class Load3d {
}
}
public setRetainViewOnReload(value: boolean): void {
this.retainViewOnReload = value
}
private async _loadModelInternal(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
this.cameraManager.reset()
this.controlsManager.reset()
// First load always uses default framing; retain only applies on reload.
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
: null
if (!shouldRetainView) {
this.cameraManager.reset()
this.controlsManager.reset()
}
this.gizmoManager.detach()
this.modelManager.clearModel()
this.animationManager.dispose()
@@ -583,6 +610,18 @@ class Load3d {
this.modelManager.currentModel,
this.modelManager.originalModel
)
this.hasLoadedModel = true
}
if (savedCameraState) {
// setupForModel runs during loadModel and clobbers the camera; restore on top.
if (
savedCameraState.cameraType !==
this.cameraManager.getCurrentCameraType()
) {
this.toggleCamera(savedCameraState.cameraType)
}
this.cameraManager.setCameraState(savedCameraState)
}
this.handleResize()
@@ -599,7 +638,7 @@ class Load3d {
}
getCurrentModelCapabilities(): ModelAdapterCapabilities {
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
return this.adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES
}
clearModel(): void {
@@ -607,6 +646,7 @@ class Load3d {
this.gizmoManager.detach()
this.modelManager.clearModel()
this.adapterRef.current = null
this.hasLoadedModel = false
this.forceRender()
}
@@ -887,11 +927,31 @@ class Load3d {
return this.gizmoManager.getTransform()
}
public getModelInfo(): Model3DTransform | null {
return this.gizmoManager.getModelInfo()
}
public fitToViewer(): void {
this.modelManager.fitToViewer()
this.forceRender()
}
public centerCameraOnModel(): void {
const bounds = this.modelManager.getCurrentBounds()
if (!bounds || bounds.isEmpty()) return
const center = bounds.getCenter(new THREE.Vector3())
const camera = this.cameraManager.activeCamera
const controls = this.controlsManager.controls
const offset = center.clone().sub(camera.position)
camera.position.add(offset)
controls.target.add(offset)
camera.updateMatrixWorld(true)
controls.update()
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()

View File

@@ -7,7 +7,11 @@ import type {
ModelManagerInterface
} from './interfaces'
import { LoaderManager } from './LoaderManager'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
function makeEventManagerStub() {
return {
@@ -28,6 +32,12 @@ type ModelManagerStub = {
originalURL: string | null
}
const STUB_CAPS = {} as ModelAdapterCapabilities
const loadResult = (object: THREE.Object3D) => ({
object,
capabilities: STUB_CAPS
})
function makeModelManagerStub(): ModelManagerStub {
return {
clearModel: vi.fn(),
@@ -41,14 +51,21 @@ function makeModelManagerStub(): ModelManagerStub {
}
}
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
getPLYEngineMock: vi.fn<() => string>(),
addAlert: vi.fn()
}))
const {
meshLoad,
splatLoad,
pointCloudLoad,
fetchModelDataMock,
isGaussianSplatPLYMock,
addAlert
} = vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
fetchModelDataMock: vi.fn<() => Promise<ArrayBuffer>>(),
isGaussianSplatPLYMock: vi.fn<(b: ArrayBuffer) => Promise<boolean>>(),
addAlert: vi.fn()
}))
vi.mock('./MeshModelAdapter', () => ({
MeshModelAdapter: class {
@@ -65,19 +82,35 @@ vi.mock('./PointCloudModelAdapter', () => ({
readonly extensions = ['ply'] as const
readonly capabilities = {}
load = pointCloudLoad
},
getPLYEngine: () => getPLYEngineMock()
}
}))
vi.mock('./SplatModelAdapter', () => ({
SplatModelAdapter: class {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
readonly capabilities = {}
matches = async (
ext: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean> => {
if (ext !== 'ply') return true
return isGaussianSplatPLYMock(await fetchBytes())
}
load = splatLoad
}
}))
vi.mock('./ModelAdapter', async () => {
const actual =
await vi.importActual<typeof import('./ModelAdapter')>('./ModelAdapter')
return { ...actual, fetchModelData: fetchModelDataMock }
})
vi.mock('@/scripts/metadata/ply', () => ({
isGaussianSplatPLY: isGaussianSplatPLYMock
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
@@ -87,7 +120,10 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
}))
type LoaderManagerInternals = {
pickAdapter(extension: string): ModelAdapter | null
pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null>
}
function makeLoaderManager() {
@@ -98,21 +134,21 @@ function makeLoaderManager() {
eventManager
)
const internals = lm as unknown as LoaderManagerInternals
return {
lm,
modelManager,
eventManager,
pick: internals.pickAdapter.bind(lm)
}
const pick = (ext: string) =>
internals.pickAdapter.call(lm, ext, () =>
fetchModelDataMock()
) as Promise<ModelAdapter | null>
return { lm, modelManager, eventManager, pick }
}
describe('LoaderManager', () => {
beforeEach(() => {
vi.clearAllMocks()
getPLYEngineMock.mockReturnValue('three')
meshLoad.mockResolvedValue(null)
splatLoad.mockResolvedValue(null)
pointCloudLoad.mockResolvedValue(null)
fetchModelDataMock.mockResolvedValue(new ArrayBuffer(0))
isGaussianSplatPLYMock.mockResolvedValue(false)
})
describe('getCurrentAdapter', () => {
@@ -123,7 +159,7 @@ describe('LoaderManager', () => {
it('exposes the picked adapter after a successful load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
@@ -132,7 +168,7 @@ describe('LoaderManager', () => {
it('resets to null at the start of a new load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -144,7 +180,7 @@ describe('LoaderManager', () => {
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -195,7 +231,10 @@ describe('LoaderManager', () => {
}
let adapterDuringClear: ModelAdapter | null | undefined
const adapterRef = { current: oldAdapter as ModelAdapter | null }
const adapterRef = {
current: oldAdapter as ModelAdapter | null,
capabilities: oldAdapter.capabilities as ModelAdapterCapabilities | null
}
const lm = new LoaderManager(
modelManager,
eventManager,
@@ -223,8 +262,8 @@ describe('LoaderManager', () => {
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
resolveSplatLoad = resolve
})
splatLoad.mockReturnValueOnce(slowSplatLoad)
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
splatLoad.mockReturnValueOnce(slowSplatLoad.then(loadResult))
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
const aPromise = lm.loadModel('api/view?filename=a.splat')
@@ -243,42 +282,36 @@ describe('LoaderManager', () => {
describe('pickAdapter', () => {
it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
(ext) => {
async (ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('mesh')
expect((await pick(ext))?.kind).toBe('mesh')
}
)
it.for(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
(ext) => {
async (ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('splat')
expect((await pick(ext))?.kind).toBe('splat')
}
)
it('routes .ply to the point-cloud adapter for the default three engine', () => {
getPLYEngineMock.mockReturnValue('three')
it('routes .ply to the splat adapter when the bytes look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
expect((await pick('ply'))?.kind).toBe('splat')
})
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
getPLYEngineMock.mockReturnValue('fastply')
it('falls back to the point-cloud adapter for .ply that is not 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
expect((await pick('ply'))?.kind).toBe('pointCloud')
})
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
getPLYEngineMock.mockReturnValue('sparkjs')
it('returns null for unknown extensions', async () => {
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('splat')
})
it('returns null for unknown extensions', () => {
const { pick } = makeLoaderManager()
expect(pick('xyz')).toBeNull()
expect(pick('')).toBeNull()
expect(await pick('xyz')).toBeNull()
expect(await pick('')).toBeNull()
})
})
@@ -348,7 +381,7 @@ describe('LoaderManager', () => {
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
meshLoad.mockResolvedValueOnce(loaded)
meshLoad.mockResolvedValueOnce(loadResult(loaded))
await lm.loadModel('api/view?filename=cube.glb')
@@ -366,7 +399,7 @@ describe('LoaderManager', () => {
it('emits modelLoadingEnd when the load completes', async () => {
const { lm, eventManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
@@ -378,7 +411,7 @@ describe('LoaderManager', () => {
it('forwards a decoded path and filename to the adapter', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel(
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
@@ -390,32 +423,105 @@ describe('LoaderManager', () => {
registerOriginalMaterial: expect.any(Function)
}),
'api/view?type=output&subfolder=nested%2Fdir&filename=',
'cube.glb'
'cube.glb',
expect.any(Function)
)
})
it('defaults the path to type=input when no type param is given', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(meshLoad).toHaveBeenCalledWith(
expect.anything(),
'api/view?type=input&subfolder=&filename=',
'cube.glb'
'cube.glb',
expect.any(Function)
)
})
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
getPLYEngineMock.mockReturnValue('sparkjs')
it('routes .ply to the point-cloud adapter when the header does not look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('reroutes .ply through the splat adapter when the header looks like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(splatLoad).toHaveBeenCalled()
expect(pointCloudLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('splat')
})
it('shares a single fetch between matches() and load() so .ply is not re-downloaded', async () => {
const buf = new ArrayBuffer(16)
fetchModelDataMock.mockResolvedValueOnce(buf)
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
// Adapter receives a fetchBytes function (memoized), not bytes directly.
expect(splatLoad).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
'scan.ply',
expect.any(Function)
)
// matches() called fetchBytes once; load()'s call hit the cached promise.
expect(fetchModelDataMock).toHaveBeenCalledTimes(1)
})
it('dispatches .ply via the adapter matches() tiebreaker, not extension order — a splat adapter whose matches() returns false yields to point-cloud', async () => {
const modelManager =
makeModelManagerStub() as unknown as ConstructorParameters<
typeof LoaderManager
>[0]
const eventManager = makeEventManagerStub()
// A splat adapter that ALSO claims '.ply' and is listed first.
// Without matches(), it would short-circuit. With matches() returning
// false (not a 3DGS PLY), the dispatcher must skip to the next
// candidate (point cloud).
const splatAdapter = {
kind: 'splat' as const,
extensions: ['ply', 'spz', 'splat', 'ksplat'] as const,
capabilities: {} as never,
matches: async (ext: string, fetchBytes: () => Promise<ArrayBuffer>) =>
ext === 'ply' ? isGaussianSplatPLYMock(await fetchBytes()) : true,
load: splatLoad
}
const pointCloudAdapter = {
kind: 'pointCloud' as const,
extensions: ['ply'] as const,
capabilities: {} as never,
load: pointCloudLoad
}
const lm = new LoaderManager(modelManager, eventManager, [
splatAdapter,
pointCloudAdapter
])
isGaussianSplatPLYMock.mockResolvedValue(false)
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
@@ -498,8 +604,8 @@ describe('LoaderManager', () => {
secondModel.name = 'second'
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(secondModel)
.mockImplementationOnce(() => firstLoad.then(loadResult))
.mockResolvedValueOnce(loadResult(secondModel))
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')

View File

@@ -4,9 +4,14 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { MeshModelAdapter } from './MeshModelAdapter'
import { createAdapterRef } from './ModelAdapter'
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { createAdapterRef, fetchModelData } from './ModelAdapter'
import type {
AdapterRef,
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import type {
EventManagerInterface,
@@ -36,14 +41,16 @@ function isNotFoundError(error: unknown): boolean {
}
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
* Default adapter set: mesh + splat + pointCloud. Each adapter declares the
* file extensions it owns. For shared extensions (.ply), the adapter with an
* async `matches()` tiebreaker is tried first; the unconditional adapter acts
* as the fallback — so SplatModelAdapter precedes PointCloudModelAdapter.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new PointCloudModelAdapter(),
new SplatModelAdapter()
new SplatModelAdapter(),
new PointCloudModelAdapter()
]
}
@@ -86,6 +93,7 @@ export class LoaderManager implements LoaderManagerInterface {
this.modelManager.clearModel()
this.adapterRef.current = null
this.adapterRef.capabilities = null
this.modelManager.originalURL = url
@@ -122,7 +130,8 @@ export class LoaderManager implements LoaderManagerInterface {
// can't clobber adapterRef.current that a newer load already
// wrote (or cleared).
this.adapterRef.current = result.adapter
await this.modelManager.setupModel(result.model)
this.adapterRef.capabilities = result.capabilities
await this.modelManager.setupModel(result.object)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
@@ -137,19 +146,18 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.extensions.includes(extension)
private async pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null> {
const candidates = this.adapters.filter((a) =>
a.extensions.includes(extension)
)
if (!match) return null
// PLY may be routed through the splat adapter when the PLYEngine setting
// is sparkjs. Only honor the routing when both adapters are registered.
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
if (splat) return splat
for (const adapter of candidates) {
if (!adapter.matches) return adapter
if (await adapter.matches(extension, fetchBytes)) return adapter
}
return match
return null
}
private createLoadContext(): ModelLoadContext {
@@ -170,7 +178,11 @@ export class LoaderManager implements LoaderManagerInterface {
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
): Promise<{
object: THREE.Object3D
adapter: ModelAdapter
capabilities: ModelAdapterCapabilities
} | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
@@ -188,10 +200,24 @@ export class LoaderManager implements LoaderManagerInterface {
encodeURIComponent(subfolder) +
'&filename='
const adapter = this.pickAdapter(fileExtension)
let bytesPromise: Promise<ArrayBuffer> | null = null
const fetchBytes = () => (bytesPromise ??= fetchModelData(path, filename))
const adapter = await this.pickAdapter(fileExtension, fetchBytes)
if (!adapter) return null
const model = await adapter.load(this.createLoadContext(), path, filename)
return model ? { model, adapter } : null
const loadResult = await adapter.load(
this.createLoadContext(),
path,
filename,
fetchBytes
)
return loadResult
? {
object: loadResult.object,
capabilities: loadResult.capabilities,
adapter
}
: null
}
}

View File

@@ -160,8 +160,8 @@ describe('MeshModelAdapter', () => {
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(result!.object.children[0]).toBeInstanceOf(THREE.Mesh)
})
})
@@ -179,7 +179,7 @@ describe('MeshModelAdapter', () => {
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(fbxModel)
expect(result!.object).toBe(fbxModel)
})
it('disables frustum culling on SkinnedMesh children', async () => {
@@ -224,7 +224,7 @@ describe('MeshModelAdapter', () => {
'cube.obj'
)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
})
@@ -271,7 +271,7 @@ describe('MeshModelAdapter', () => {
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
expect(computeNormals).toHaveBeenCalled()
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(scene)
expect(result!.object).toBe(scene)
})
it('also handles .gltf filenames', async () => {

View File

@@ -11,7 +11,8 @@ import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
ModelLoadContext,
ModelLoadResult
} from './ModelAdapter'
export class MeshModelAdapter implements ModelAdapter {
@@ -45,20 +46,18 @@ export class MeshModelAdapter implements ModelAdapter {
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
): Promise<ModelLoadResult | null> {
const extension = filename.split('.').pop()?.toLowerCase()
switch (extension) {
case 'stl':
return this.loadSTL(ctx, path, filename)
case 'fbx':
return this.loadFBX(ctx, path, filename)
case 'obj':
return this.loadOBJ(ctx, path, filename)
case 'gltf':
case 'glb':
return this.loadGLTF(ctx, path, filename)
}
return null
const object = await (extension === 'stl'
? this.loadSTL(ctx, path, filename)
: extension === 'fbx'
? this.loadFBX(ctx, path, filename)
: extension === 'obj'
? this.loadOBJ(ctx, path, filename)
: extension === 'gltf' || extension === 'glb'
? this.loadGLTF(ctx, path, filename)
: Promise.resolve(null))
return object ? { object, capabilities: this.capabilities } : null
}
private async loadSTL(

View File

@@ -15,7 +15,7 @@ export interface ModelLoadContext {
readonly materialMode: MaterialMode
}
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**
@@ -65,24 +65,59 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
}
/**
* Mutable handle to the currently active ModelAdapter. A single ref is
* created in `createLoad3d` and shared between LoaderManager (writer) and
* SceneModelManager + Load3d (readers), so capability/bounds/dispose lookups
* don't depend on construction order between those collaborators.
* Result returned by `ModelAdapter.load()`. Capabilities ride with the model
* because some adapters (notably PLY) produce different capability sets
* depending on the file contents — face-less point clouds expose only the
* 'pointCloud' material mode, indexed meshes expose the full set. Keeping
* capabilities per-load (not per-adapter) prevents stale state on the
* adapter instance between two successive loads.
*/
export type AdapterRef = { current: ModelAdapter | null }
export type ModelLoadResult = {
object: THREE.Object3D
capabilities: ModelAdapterCapabilities
}
export const createAdapterRef = (): AdapterRef => ({ current: null })
/**
* Mutable handle to the currently active ModelAdapter plus the capabilities
* reported by its most recent load. A single ref is created in `createLoad3d`
* and shared between LoaderManager (writer) and SceneModelManager + Load3d
* (readers), so capability/bounds/dispose lookups don't depend on
* construction order between those collaborators.
*/
export type AdapterRef = {
current: ModelAdapter | null
capabilities: ModelAdapterCapabilities | null
}
export const createAdapterRef = (): AdapterRef => ({
current: null,
capabilities: null
})
export interface ModelAdapter {
readonly kind: ModelAdapterKind
readonly extensions: readonly string[]
/**
* Default capabilities for this adapter family. `load()` may return a
* narrowed set for a specific model — read `adapterRef.capabilities` for
* the live per-model value rather than this.
*/
readonly capabilities: ModelAdapterCapabilities
/**
* Async tiebreaker when multiple adapters claim the same extension
* (e.g. .ply is shared by Gaussian splats and classic point clouds).
* Adapters that uniquely own their extensions can omit this.
*/
matches?(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean>
load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null>
filename: string,
fetchBytes?: () => Promise<ArrayBuffer>
): Promise<ModelLoadResult | null>
/**
* Optional. Return a world-space AABB for the given model. Adapters for
* renderers whose geometry is not walked by `Box3.setFromObject` (e.g.

View File

@@ -15,31 +15,40 @@ vi.mock('@/scripts/metadata/ply', () => ({
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
}))
const plyLoaderParse = vi.fn(() => makePLYGeometry({ withFaces: true }))
const fastPlyLoaderParse = vi.fn(() => makePLYGeometry({ withFaces: true }))
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
PLYLoader: class {
setPath = vi.fn()
parse = vi.fn(() => makePLYGeometry(false))
parse = plyLoaderParse
}
}))
vi.mock('./loader/FastPLYLoader', () => ({
FastPLYLoader: class {
parse = vi.fn(() => makePLYGeometry(false))
parse = fastPlyLoaderParse
}
}))
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
function makePLYGeometry(opts: {
withColors?: boolean
withFaces?: boolean
}): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
)
if (withColors) {
if (opts.withColors) {
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
}
if (opts.withFaces) {
geometry.setIndex([0, 1, 2])
}
return geometry
}
@@ -96,8 +105,8 @@ describe('PointCloudModelAdapter', () => {
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(result!.object).toBeInstanceOf(THREE.Group)
const child = result!.object.children[0]
expect(child).toBeInstanceOf(THREE.Mesh)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
})
@@ -108,9 +117,57 @@ describe('PointCloudModelAdapter', () => {
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(result!.object).toBeInstanceOf(THREE.Group)
const child = result!.object.children[0]
expect(child).toBeInstanceOf(THREE.Points)
})
it('forces Points rendering for a face-less PLY even on materialMode=original', async () => {
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('original')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
const child = result!.object.children[0]
expect(child).toBeInstanceOf(THREE.Points)
})
it('returns narrowed materialModes capability for a face-less PLY', async () => {
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
const adapter = new PointCloudModelAdapter()
const result = await adapter.load(
makeContext('original'),
'/api/view?',
'cloud.ply'
)
expect([...result!.capabilities.materialModes]).toEqual(['pointCloud'])
})
it('returns full materialModes capability for a face-bearing PLY (independent of prior loads)', async () => {
const adapter = new PointCloudModelAdapter()
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
const faceless = await adapter.load(
makeContext('original'),
'/api/view?',
'cloud.ply'
)
expect([...faceless!.capabilities.materialModes]).toEqual(['pointCloud'])
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: true }))
const faceful = await adapter.load(
makeContext('original'),
'/api/view?',
'mesh.ply'
)
expect([...faceful!.capabilities.materialModes]).toEqual([
'original',
'pointCloud',
'normal',
'wireframe'
])
})
})
})

View File

@@ -8,27 +8,30 @@ import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
ModelLoadContext,
ModelLoadResult
} from './ModelAdapter'
import type { MaterialMode } from './interfaces'
import { FastPLYLoader } from './loader/FastPLYLoader'
export function getPLYEngine(): string {
function getPLYEngine(): string {
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
}
const POINT_CLOUD_CAPABILITIES: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
exportable: true,
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
fitTargetSize: 5
}
export class PointCloudModelAdapter implements ModelAdapter {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
exportable: true,
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
fitTargetSize: 5
}
readonly capabilities = POINT_CLOUD_CAPABILITIES
private readonly plyLoader = new PLYLoader()
private readonly fastPlyLoader = new FastPLYLoader()
@@ -36,9 +39,10 @@ export class PointCloudModelAdapter implements ModelAdapter {
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const arrayBuffer = await fetchModelData(path, filename)
filename: string,
fetchBytes?: () => Promise<ArrayBuffer>
): Promise<ModelLoadResult | null> {
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
const isASCII = isPLYAsciiFormat(arrayBuffer)
const plyGeometry =
@@ -50,12 +54,18 @@ export class PointCloudModelAdapter implements ModelAdapter {
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
const hasFaces = (plyGeometry.index?.count ?? 0) > 0
if (ctx.materialMode === 'pointCloud') {
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
}
const object =
ctx.materialMode === 'pointCloud' || !hasFaces
? buildPointsGroup(ctx, plyGeometry, hasVertexColors)
: buildMeshGroup(ctx, plyGeometry, hasVertexColors)
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
const capabilities = hasFaces
? POINT_CLOUD_CAPABILITIES
: { ...POINT_CLOUD_CAPABILITIES, materialModes: ['pointCloud'] as const }
return { object, capabilities }
}
}

View File

@@ -14,6 +14,17 @@ export class SceneManager implements SceneManagerInterface {
gridHelper: THREE.GridHelper
private sparkRenderer: SparkRenderer
private nextSparkDirtyPromise: Promise<void> | null = null
private nextSparkDirtyResolve: (() => void) | null = null
awaitNextSparkDirty(): Promise<void> {
if (this.nextSparkDirtyPromise) return this.nextSparkDirtyPromise
this.nextSparkDirtyPromise = new Promise<void>((resolve) => {
this.nextSparkDirtyResolve = resolve
})
return this.nextSparkDirtyPromise
}
backgroundScene!: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
backgroundMesh: THREE.Mesh | null = null
@@ -45,9 +56,23 @@ export class SceneManager implements SceneManagerInterface {
this.getActiveCamera = getActiveCamera
// Spark 2.x requires a SparkRenderer in the scene tree to render SplatMesh
// (Gaussian splat) instances; without it splats are silent no-ops. Kept
// alive across model reloads by SceneModelManager.clearModel.
this.sparkRenderer = new SparkRenderer({ renderer })
// instances; without it splats are silent no-ops.
//
// onDirty fires twice per splat first-paint cycle: once from updateInternal
// (data uploaded) and again from driveSort (sort completed; line 1105 in
// SparkRenderer.ts). We expose it as a passive promise — awaiters get
// notified, but the callback itself does NOT trigger a render. Wiring
// forceRender directly into onDirty caused a per-frame render-setDirty
// cascade that made splats visibly "balloon" during camera interaction.
this.sparkRenderer = new SparkRenderer({
renderer,
onDirty: () => {
const resolve = this.nextSparkDirtyResolve
this.nextSparkDirtyResolve = null
this.nextSparkDirtyPromise = null
resolve?.()
}
})
this.scene.add(this.sparkRenderer)
this.gridHelper = new THREE.GridHelper(20, 20)

View File

@@ -435,6 +435,11 @@ export class SceneModelManager implements ModelManagerInterface {
)
}
getCurrentBounds(): THREE.Box3 | null {
if (!this.currentModel) return null
return this.computeWorldBounds(this.currentModel)
}
async setupModel(model: THREE.Object3D): Promise<void> {
this.currentModel = model
model.name = 'MainModel'
@@ -456,6 +461,12 @@ export class SceneModelManager implements ModelManagerInterface {
this.setMaterialMode(pendingMaterialMode)
}
const validModes = this.getCurrentCapabilities().materialModes
if (validModes.length > 0 && !validModes.includes(this.materialMode)) {
this.materialMode = validModes[0]
this.eventManager.emitEvent('materialModeChange', this.materialMode)
}
if (this.currentUpDirection !== 'original') {
this.setUpDirection(this.currentUpDirection)
}

View File

@@ -6,7 +6,7 @@ import type { ModelLoadContext } from './ModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
const splatMeshSpies = {
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>(),
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer; fileName?: string }) => void>(),
dispose: vi.fn(),
getBoundingBox: vi.fn(
() =>
@@ -23,7 +23,7 @@ vi.mock('@sparkjsdev/spark', async () => {
dispose = splatMeshSpies.dispose
getBoundingBox = splatMeshSpies.getBoundingBox
constructor(opts: { fileBytes: ArrayBuffer }) {
constructor(opts: { fileBytes: ArrayBuffer; fileName?: string }) {
super()
splatMeshSpies.ctor(opts)
}
@@ -69,7 +69,7 @@ describe('SplatModelAdapter', () => {
it('handles the Gaussian splat extensions', () => {
const adapter = new SplatModelAdapter()
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat', 'ply'])
})
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
@@ -85,12 +85,18 @@ describe('SplatModelAdapter', () => {
'/api/view?',
'scene.splat'
)
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({ fileBytes: buf })
expect(result).toBeInstanceOf(THREE.Group)
expect(result.children).toHaveLength(1)
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({
fileBytes: buf,
fileName: 'scene.splat'
})
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(result!.object.children).toHaveLength(1)
expect(result!.capabilities.lighting).toBe(false)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
expect(ctx.setOriginalModel).toHaveBeenCalledWith(
result!.object.children[0]
)
})
it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => {
@@ -100,7 +106,7 @@ describe('SplatModelAdapter', () => {
'scene.splat'
)
const splat = result.children[0]
const splat = result!.object.children[0]
expect(splat.quaternion.x).toBe(1)
expect(splat.quaternion.y).toBe(0)
expect(splat.quaternion.z).toBe(0)
@@ -121,11 +127,12 @@ describe('SplatModelAdapter', () => {
describe('computeBounds', () => {
it('returns the SplatMesh bounding box transformed to world space', async () => {
const adapter = new SplatModelAdapter()
const group = await adapter.load(
const result = await adapter.load(
makeContext(),
'/api/view?',
'scene.splat'
)
const group = result!.object
const splat = group.children[0]
splat.position.set(10, 0, 0)
@@ -152,13 +159,13 @@ describe('SplatModelAdapter', () => {
describe('disposeModel', () => {
it('calls dispose on every SplatMesh in the model tree', async () => {
const adapter = new SplatModelAdapter()
const group = await adapter.load(
const result = await adapter.load(
makeContext(),
'/api/view?',
'scene.splat'
)
adapter.disposeModel(group)
adapter.disposeModel(result!.object)
expect(splatMeshSpies.dispose).toHaveBeenCalledOnce()
})

View File

@@ -1,16 +1,19 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { isGaussianSplatPLY } from '@/scripts/metadata/ply'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
ModelLoadContext,
ModelLoadResult
} from './ModelAdapter'
export class SplatModelAdapter implements ModelAdapter {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
@@ -21,21 +24,33 @@ export class SplatModelAdapter implements ModelAdapter {
fitTargetSize: 20
}
async matches(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean> {
if (extension !== 'ply') return true
return isGaussianSplatPLY(await fetchBytes())
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await fetchModelData(path, filename)
filename: string,
fetchBytes?: () => Promise<ArrayBuffer>
): Promise<ModelLoadResult> {
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
const splatMesh = new SplatMesh({
fileBytes: arrayBuffer,
fileName: filename
})
await splatMesh.initialized
splatMesh.quaternion.set(1, 0, 0, 0)
ctx.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
return { object: splatGroup, capabilities: this.capabilities }
}
computeBounds(model: THREE.Object3D): THREE.Box3 | null {

View File

@@ -125,7 +125,11 @@ vi.mock('./Load3d', () => ({
}
}))
type FakeLoaderManager = { adapterRefArg: { current: ModelAdapter | null } }
type FakeAdapterRef = {
current: ModelAdapter | null
capabilities: ModelAdapterCapabilities | null
}
type FakeLoaderManager = { adapterRefArg: FakeAdapterRef }
type FakeSceneModelManager = {
getCurrentCapabilities: () => unknown
getBoundsFromAdapter: (model: unknown) => unknown
@@ -134,7 +138,7 @@ type FakeSceneModelManager = {
}
type FakeLoad3d = {
deps: {
adapterRef: { current: ModelAdapter | null }
adapterRef: FakeAdapterRef
loaderManager: FakeLoaderManager
modelManager: FakeSceneModelManager
}
@@ -222,6 +226,7 @@ describe('createLoad3d', () => {
function withAdapter(adapter: ModelAdapter) {
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
instance.deps.adapterRef.current = adapter
instance.deps.adapterRef.capabilities = adapter.capabilities
return instance
}

View File

@@ -84,7 +84,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
getActiveCamera,
(size, center) => cameraManager.setupForModel(size, center),
(model) => gizmoManager.setupForModel(model),
() => adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
() => adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
(model) => adapterRef.current?.computeBounds?.(model) ?? null,
(model) => adapterRef.current?.disposeModel?.(model),
() => adapterRef.current?.defaultCameraPose?.() ?? null

View File

@@ -15,18 +15,48 @@ export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
interface CameraQuaternion {
x: number
y: number
z: number
w: number
}
interface CameraFrustum {
left: number
right: number
top: number
bottom: number
}
export interface CameraState {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: CameraType
quaternion?: CameraQuaternion
fov?: number
aspect?: number
near?: number
far?: number
frustum?: CameraFrustum
}
// Coordinate system: right-handed, Y-up, world space
export interface Model3DTransform {
position: { x: number; y: number; z: number } // scene units
quaternion: { x: number; y: number; z: number; w: number } // normalized, dimensionless; world rotation
scale: { x: number; y: number; z: number } // dimensionless multiplier
}
export type Model3DInfo = Model3DTransform[]
export interface SceneConfig {
showGrid: boolean
backgroundColor: string
backgroundImage?: string
backgroundRenderMode?: BackgroundRenderModeType
models?: Model3DInfo
}
export type GizmoMode = 'translate' | 'rotate' | 'scale'
@@ -50,6 +80,7 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -0,0 +1,18 @@
/**
* Canonical lists of node types backed by the Load3D viewer infrastructure.
* Adding a new node type that uses the viewer = one line change here.
*/
const LOAD3D_PREVIEW_NODES = new Set([
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)
export const isLoad3dNode = (nodeType: string): boolean =>
LOAD3D_ALL_NODES.has(nodeType)

View File

@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))
type Hook = (
@@ -83,7 +84,13 @@ describe('load3dLazy', () => {
expect(enabledExtensionsGetter).not.toHaveBeenCalled()
})
it.for(['Load3D', 'Preview3D', 'SaveGLB'])(
it.for([
'Load3D',
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud',
'SaveGLB'
])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {
const { hook } = await loadLazyExtensionFresh()

View File

@@ -15,7 +15,7 @@ import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const LOAD3D_NODE_TYPES = new Set(['Load3D', 'Preview3D', 'SaveGLB'])
import { isLoad3dNode } from './load3d/nodeTypes'
let load3dExtensionsLoaded = false
let load3dExtensionsLoading: Promise<ComfyExtension[]> | null = null
@@ -34,8 +34,12 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
load3dExtensionsLoading = (async () => {
const before = new Set(useExtensionStore().enabledExtensions)
// Import both extensions - they will self-register via useExtensionService()
await Promise.all([import('./load3d'), import('./saveMesh')])
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
load3dExtensionsLoaded = true
return useExtensionStore().enabledExtensions.filter(
(ext) => !before.has(ext)
@@ -45,13 +49,6 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
return load3dExtensionsLoading
}
/**
* Check if a node type is a 3D node that requires THREE.js
*/
function isLoad3dNodeType(nodeTypeName: string): boolean {
return LOAD3D_NODE_TYPES.has(nodeTypeName)
}
// Register a lightweight extension that triggers lazy loading
useExtensionService().registerExtension({
name: 'Comfy.Load3DLazy',
@@ -60,7 +57,7 @@ useExtensionService().registerExtension({
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) {
if (isLoad3dNodeType(nodeData.name)) {
if (isLoad3dNode(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
if (nodeData.name === 'Load3D') {

View File

@@ -0,0 +1,372 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyExtension } from '@/types/comfy'
const {
registerExtensionMock,
waitForLoad3dMock,
onLoad3dReadyMock,
configureForSaveMeshMock,
getLoad3dMock,
toastAddAlertMock,
getNodeByLocatorIdMock,
nodeToLoad3dMapMock
} = vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
waitForLoad3dMock: vi.fn(),
onLoad3dReadyMock: vi.fn(),
configureForSaveMeshMock: vi.fn(),
getLoad3dMock: vi.fn(),
toastAddAlertMock: vi.fn(),
getNodeByLocatorIdMock: vi.fn(),
nodeToLoad3dMapMock: new Map()
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension: registerExtensionMock })
}))
vi.mock('@/services/load3dService', () => ({
useLoad3dService: () => ({ getLoad3d: getLoad3dMock })
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap: nodeToLoad3dMapMock
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configureForSaveMesh = configureForSaveMeshMock
}
}))
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: {} }
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: getNodeByLocatorIdMock
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: toastAddAlertMock })
}))
type ExtCreated = ComfyExtension & {
nodeCreated: (node: LGraphNode) => Promise<void>
getNodeMenuItems: (node: LGraphNode) => unknown[]
onNodeOutputsUpdated: (
nodeOutputs: Record<string, Record<string, unknown>>
) => void
}
async function loadExtensionsFresh(): Promise<{
splatExt: ExtCreated
pointCloudExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3dPreviewExtensions')
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
return {
splatExt: splatCall[0] as ExtCreated,
pointCloudExt: pointCloudCall[0] as ExtCreated
}
}
interface FakeLoad3d {
whenLoadIdle: () => Promise<void>
isSplatModel: ReturnType<typeof vi.fn>
forceRender: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
setTargetSize: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
getModelInfo: ReturnType<typeof vi.fn>
cameraManager: { perspectiveCamera: { fov: number } }
currentLoadGeneration: number
}
function makeLoad3dMock(): FakeLoad3d {
return {
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn(() => false),
forceRender: vi.fn(),
setCameraState: vi.fn(),
setTargetSize: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective'),
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
getModelInfo: vi.fn(() => null),
cameraManager: { perspectiveCamera: { fov: 75 } },
currentLoadGeneration: 0
}
}
interface FakeWidget {
name: string
value: unknown
}
function makePreviewNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
widgets: FakeWidget[]
}> = {}
): LGraphNode {
return {
constructor: {
comfyClass: overrides.comfyClass ?? 'PreviewGaussianSplat'
},
size: [400, 550],
setSize: vi.fn(),
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
function setupBaseMocks() {
vi.clearAllMocks()
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
cb(makeLoad3dMock())
})
onLoad3dReadyMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
cb(makeLoad3dMock())
})
}
describe('load3dPreviewExtensions module registration', () => {
beforeEach(setupBaseMocks)
it('registers both preview extensions on import', async () => {
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
})
})
describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not PreviewGaussianSplat', async () => {
const { splatExt } = await loadExtensionsFresh()
const node = makePreviewNode({ comfyClass: 'OtherNode' })
await splatExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('triggers a model load against the output folder on execute', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode()
await splatExt.nodeCreated(node)
node.onExecuted!({ result: ['scene.ply'] })
expect(node.properties['Last Time Model File']).toBe('scene.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'scene.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('persists backend-provided camera_info into node.properties so onLoad3dReady can restore it after remount', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode()
const cameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1
}
await splatExt.nodeCreated(node)
node.onExecuted!({ result: ['scene.ply', cameraState] })
const cameraConfig = node.properties['Camera Config'] as
| { state?: typeof cameraState }
| undefined
expect(cameraConfig?.state).toEqual(cameraState)
})
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const widthWidget: FakeWidget & { callback?: (v: number) => void } = {
name: 'width',
value: 800
}
const heightWidget: FakeWidget & { callback?: (v: number) => void } = {
name: 'height',
value: 600
}
const node = makePreviewNode({
widgets: [
{ name: 'model_file', value: '' },
{ name: 'image', value: '' },
widthWidget,
heightWidget
]
})
await splatExt.nodeCreated(node)
expect(load3d.setTargetSize).toHaveBeenCalledWith(800, 600)
expect(typeof widthWidget.callback).toBe('function')
expect(typeof heightWidget.callback).toBe('function')
widthWidget.callback!(1024)
expect(load3d.setTargetSize).toHaveBeenLastCalledWith(1024, 600)
})
it("installs a sceneWidget.serializeValue that returns the viewer's current camera_info + model_3d_info", async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
const cameraState = { position: { x: 1, y: 2, z: 3 } }
load3d.getCameraState = vi.fn(() => cameraState)
load3d.getModelInfo = vi.fn(() => ({
position: { x: 0, y: 0, z: 0 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 }
}))
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const sceneWidget: FakeWidget & {
serializeValue?: () => Promise<unknown>
} = { name: 'image', value: '' }
const node = makePreviewNode({
widgets: [{ name: 'model_file', value: '' }, sceneWidget]
})
nodeToLoad3dMapMock.set(node, load3d)
await splatExt.nodeCreated(node)
expect(typeof sceneWidget.serializeValue).toBe('function')
const payload = (await sceneWidget.serializeValue!()) as {
camera_info: unknown
model_3d_info: unknown[]
}
expect(payload.camera_info).toEqual(cameraState)
expect(payload.model_3d_info).toHaveLength(1)
})
it('shows an error toast when onExecuted has no file path', async () => {
const { splatExt } = await loadExtensionsFresh()
const node = makePreviewNode()
await splatExt.nodeCreated(node)
node.onExecuted!({ result: [] })
expect(toastAddAlertMock).toHaveBeenCalledWith(
'toastMessages.unableToGetModelFilePath'
)
})
})
describe('Comfy.PreviewPointCloud.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not PreviewPointCloud', async () => {
const { pointCloudExt } = await loadExtensionsFresh()
const node = makePreviewNode({ comfyClass: 'OtherNode' })
await pointCloudExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('triggers a model load against the output folder on execute', async () => {
const { pointCloudExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode({ comfyClass: 'PreviewPointCloud' })
await pointCloudExt.nodeCreated(node)
node.onExecuted!({ result: ['pointcloud.ply'] })
expect(node.properties['Last Time Model File']).toBe('pointcloud.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'pointcloud.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
})
describe('Comfy.PreviewGaussianSplat.onNodeOutputsUpdated', () => {
beforeEach(setupBaseMocks)
it('skips entries whose comfyClass is not PreviewGaussianSplat', async () => {
const { splatExt } = await loadExtensionsFresh()
getNodeByLocatorIdMock.mockReturnValue(makePreviewNode({ comfyClass: 'X' }))
splatExt.onNodeOutputsUpdated({
'node:1': { result: ['scene.ply'] }
})
expect(waitForLoad3dMock).not.toHaveBeenCalled()
})
it('skips entries with no result file path', async () => {
const { splatExt } = await loadExtensionsFresh()
getNodeByLocatorIdMock.mockReturnValue(makePreviewNode())
splatExt.onNodeOutputsUpdated({ 'node:1': { result: [] } })
expect(waitForLoad3dMock).not.toHaveBeenCalled()
})
})
describe('Comfy.PreviewGaussianSplat.getNodeMenuItems', () => {
beforeEach(setupBaseMocks)
it('returns [] for non-PreviewGaussianSplat nodes', async () => {
const { splatExt } = await loadExtensionsFresh()
const items = splatExt.getNodeMenuItems(
makePreviewNode({ comfyClass: 'OtherNode' })
)
expect(items).toEqual([])
})
it('returns [] for splat models', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.isSplatModel = vi.fn(() => true)
getLoad3dMock.mockReturnValue(load3d)
const items = splatExt.getNodeMenuItems(makePreviewNode())
expect(items).toEqual([])
})
})

View File

@@ -0,0 +1,212 @@
import { nextTick } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { NodeExecutionOutput, NodeOutputWith } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import type { ComfyExtension } from '@/types/comfy'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
type PreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, Model3DInfo?]
}>
function applyResultToLoad3d(
node: LGraphNode,
load3d: Load3d,
filePath: string,
cameraState: CameraState | undefined
): void {
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
if (cameraState) {
const existing = node.properties['Camera Config'] as
| CameraConfig
| undefined
node.properties['Camera Config'] = {
cameraType: load3d.getCurrentCameraType(),
fov: 75,
...existing,
state: cameraState
}
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('output', normalizedPath, {
silentOnNotFound: true
})
const targetGeneration = load3d.currentLoadGeneration
void load3d.whenLoadIdle().then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
load3d.forceRender()
})
}
function createPreview3DExtension(
comfyClass: string,
extensionName: string
): ComfyExtension {
const applyPreviewOutput = (
node: LGraphNode,
result: NonNullable<PreviewOutput['result']>
): void => {
const filePath = result[0]
const cameraState = result[1]
if (!filePath) return
useLoad3d(node).waitForLoad3d((load3d) => {
applyResultToLoad3d(node, load3d, filePath, cameraState)
})
}
return {
name: extensionName,
onNodeOutputsUpdated(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
) {
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
const result = (output as PreviewOutput).result
if (!result?.[0]) continue
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node || node.constructor.comfyClass !== comfyClass) continue
applyPreviewOutput(node, result)
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== comfyClass) return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== comfyClass) return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
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('output', lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const targetGeneration = load3d.currentLoadGeneration
void load3d.whenLoadIdle().then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
load3d.forceRender()
})
})
waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
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) => {
load3d.setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
load3d.setTargetSize(widthWidget.value as number, value)
}
}
if (sceneWidget) {
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: PreviewOutput) {
onExecuted?.call(this, output)
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
applyResultToLoad3d(node, load3d, filePath, result?.[1])
}
})
}
}
}
useExtensionService().registerExtension(
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
)
useExtensionService().registerExtension(
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
)

View File

@@ -155,6 +155,7 @@ export const i18n = createI18n({
/** Convenience shorthand: i18n.global */
export const { t, te, d } = i18n.global
const { tm } = i18n.global
/**
* Safe translation function that returns the fallback message if the key is not found.
@@ -166,3 +167,17 @@ export function st(key: string, fallbackMessage: string) {
// The normal defaultMsg overload fails in some cases for custom nodes
return te(key) ? t(key) : fallbackMessage
}
/**
* Safe raw translation function for strings that may contain i18n syntax.
*
* @param key - The key for the raw locale message.
* @param fallbackMessage - The fallback message to use if the key is not found
* or the locale message is not a string.
*/
export function stRaw(key: string, fallbackMessage: string) {
if (!te(key)) return fallbackMessage
const message = tm(key)
return typeof message === 'string' ? message : fallbackMessage
}

View File

@@ -1670,6 +1670,7 @@ export class LGraph
// Record state before conversion for proper undo support
this.beforeChange()
this.canvasAction((c) => c.emitBeforeChange())
try {
function extractNodes(item: Positionable): Positionable[] {
@@ -1684,6 +1685,7 @@ export class LGraph
} finally {
// Mark state change complete for proper undo support
this.afterChange()
this.canvasAction((c) => c.emitAfterChange())
}
}

View File

@@ -265,7 +265,7 @@ export abstract class SubgraphIONodeBase<
break
}
this.subgraph.setDirtyCanvas(true)
this.subgraph.setDirtyCanvas(true, true)
}
/**

View File

@@ -223,6 +223,70 @@ describe('resolveOutputAssetItems', () => {
expect(results[0].display_name).toBeUndefined()
})
/**
* Two output records that share the composite
* `<nodeId>-<subfolder>-<filename>` key produce colliding AssetItem ids,
* which makes Vue's keyed v-for in VirtualGrid reuse one DOM node and
* visibly duplicate the asset on scroll. A resolved job's asset list
* must contain each composite key at most once.
*/
it('deduplicates outputs that share the same composite output key', async () => {
const first = createOutput({
filename: 'ComfyUI_00001_.png',
nodeId: '9',
subfolder: '',
url: 'https://example.com/first.png'
})
const duplicate = createOutput({
filename: 'ComfyUI_00001_.png',
nodeId: '9',
subfolder: '',
url: 'https://example.com/duplicate.png'
})
const distinct = createOutput({
filename: 'ComfyUI_00002_.png',
nodeId: '9',
subfolder: '',
url: 'https://example.com/distinct.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-dedupe',
nodeId: '9',
subfolder: '',
outputCount: 3,
allOutputs: [first, duplicate, distinct]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(2)
const ids = results.map((asset) => asset.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('collapses to a single asset when every output shares the same composite key', async () => {
const shared = {
filename: 'ComfyUI_00001_.png',
nodeId: '9',
subfolder: ''
}
const metadata: OutputAssetMetadata = {
jobId: 'job-all-dup',
nodeId: shared.nodeId,
subfolder: shared.subfolder,
outputCount: 3,
allOutputs: [
createOutput({ ...shared, url: 'https://example.com/a.png' }),
createOutput({ ...shared, url: 'https://example.com/b.png' }),
createOutput({ ...shared, url: 'https://example.com/c.png' })
]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',

View File

@@ -63,6 +63,19 @@ export function getOutputKey({
return `${nodeId}-${subfolder}-${filename}`
}
/**
* Maps a job's outputs to AssetItems with ids derived from the composite
* `<nodeId>-<subfolder>-<filename>` key. Records sharing a composite key are
* dropped after the first to keep `:key` unique in VirtualGrid — colliding
* ids cause Vue to reuse one DOM node and visibly duplicate the asset on
* scroll.
*
* The dedupe key ignores `type`/`mediaType`/`format`/`frame_rate` because
* those fields don't appear in `AssetItem.id`, so widening the key would
* just let the collision propagate. The kept copy is the first one seen;
* callers that reverse the input (e.g. `resolveOutputAssetItems`) retain
* the last record in the API's original order.
*/
function mapOutputsToAssetItems({
jobId,
outputs,
@@ -72,12 +85,17 @@ function mapOutputsToAssetItems({
excludeOutputKey
}: OutputAssetMapOptions): AssetItem[] {
const createdAtValue = createdAt ?? new Date().toISOString()
const seenOutputKeys = new Set<string>()
return outputs.reduce<AssetItem[]>((items, output) => {
const outputKey = getOutputKey(output)
if (!output.filename || !outputKey || outputKey === excludeOutputKey) {
return items
}
if (seenOutputKeys.has(outputKey)) {
return items
}
seenOutputKeys.add(outputKey)
items.push({
id: `${jobId}-${outputKey}`,

View File

@@ -1,22 +1,46 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadModel,
fetchModelMetadata,
isModelDownloadable,
toBrowsableUrl
} from './missingModelDownload'
const fetchMock = vi.fn()
const { fetchMock, mockIsDesktop, mockSidebarTabStore, mockStartDownload } =
vi.hoisted(() => ({
fetchMock: vi.fn(),
mockIsDesktop: { value: false },
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
mockStartDownload: vi.fn()
}))
vi.stubGlobal('fetch', fetchMock)
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
vi.mock('@/stores/electronDownloadStore', () => ({}))
vi.mock('@/platform/distribution/types', () => ({
get isDesktop() {
return mockIsDesktop.value
}
}))
vi.mock('@/stores/electronDownloadStore', () => ({
useElectronDownloadStore: () => ({
start: mockStartDownload
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
let testId = 0
describe('fetchModelMetadata', () => {
beforeEach(() => {
fetchMock.mockReset()
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
testId++
})
@@ -213,3 +237,31 @@ describe('isModelDownloadable', () => {
).toBe(false)
})
})
describe('downloadModel', () => {
beforeEach(() => {
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
})
it('opens the model library sidebar before starting a desktop download', () => {
mockIsDesktop.value = true
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(mockSidebarTabStore.activeSidebarTabId).toBe('model-library')
expect(mockStartDownload).toHaveBeenCalledWith({
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
})
})

View File

@@ -1,6 +1,7 @@
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const ALLOWED_SOURCES = [
'https://civitai.com/',
@@ -26,6 +27,8 @@ const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
const MODEL_LIBRARY_TAB_ID = 'model-library'
export interface ModelWithUrl {
name: string
url: string
@@ -72,6 +75,7 @@ export function downloadModel(
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
useSidebarTabStore().activeSidebarTabId = MODEL_LIBRARY_TAB_ID
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],

View File

@@ -0,0 +1,120 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { i18n, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeTooltips } from './useNodeTooltips'
const jsonTooltip =
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
const positiveCoordsTooltipKey =
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const positiveCoordsWidget: SafeWidgetData = {
name: 'positive_coords',
type: 'STRING'
}
function mergeOutputTooltipMessage(tooltip: string | null) {
i18n.global.mergeLocaleMessage('en', {
nodeDefs: {
SAM3_Detect: {
outputs: {
0: {
tooltip
}
}
}
}
})
}
const sam3DetectNodeDef: ComfyNodeDef = {
name: 'SAM3_Detect',
display_name: 'SAM3 Detect',
category: 'detection/',
python_module: 'comfy_extras.nodes_sam3',
description: '',
input: {
required: {},
optional: {
positive_coords: [
'STRING',
{
tooltip: jsonTooltip,
forceInput: true
}
]
}
},
output: ['MASK'],
output_name: ['masks'],
output_tooltips: [jsonTooltip],
output_node: false,
deprecated: false,
experimental: false
}
describe('useNodeTooltips', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(useSettingStore(), 'get').mockImplementation(
<K extends keyof Settings>(key: K): Settings[K] => {
switch (key) {
case 'Comfy.EnableTooltips':
return true as Settings[K]
case 'LiteGraph.Node.TooltipDelay':
return 500 as Settings[K]
default:
return undefined as Settings[K]
}
}
)
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
mergeOutputTooltipMessage(jsonTooltip)
})
afterEach(() => {
mergeOutputTooltipMessage(null)
vi.restoreAllMocks()
})
it('reads JSON examples in node metadata without i18n placeholder errors', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { getInputSlotTooltip } = useNodeTooltips('SAM3_Detect')
// Ensure this exercises the bundled i18n path, not only metadata fallback.
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(getInputSlotTooltip('positive_coords')).toBe(jsonTooltip)
expect(consoleError).not.toHaveBeenCalled()
})
it('reads input-based widget tooltips without i18n placeholder errors', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { getWidgetTooltip } = useNodeTooltips('SAM3_Detect')
expect(te(positiveCoordsTooltipKey)).toBe(true)
expect(getWidgetTooltip(positiveCoordsWidget)).toBe(jsonTooltip)
expect(consoleError).not.toHaveBeenCalled()
})
it('reads output slot tooltips without i18n placeholder errors', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { getOutputSlotTooltip } = useNodeTooltips('SAM3_Detect')
expect(te(outputTooltipKey)).toBe(true)
expect(getOutputSlotTooltip(0)).toBe(jsonTooltip)
expect(consoleError).not.toHaveBeenCalled()
})
})

View File

@@ -6,7 +6,7 @@ import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st } from '@/i18n'
import { st, stRaw } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -119,7 +119,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
const inputTooltip = nodeDef.value.inputs?.[slotName]?.tooltip ?? ''
return st(key, inputTooltip)
return stRaw(key, inputTooltip)
}
/**
@@ -130,7 +130,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
const outputTooltip = nodeDef.value.outputs?.[slotIndex]?.tooltip ?? ''
return st(key, outputTooltip)
return stRaw(key, outputTooltip)
}
/**
@@ -146,7 +146,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
// Then try input-based tooltip lookup
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(widget.name)}.tooltip`
const inputTooltip = nodeDef.value.inputs?.[widget.name]?.tooltip ?? ''
return st(key, inputTooltip)
return stRaw(key, inputTooltip)
}
/**

View File

@@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => {
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey for widgets without stable identity', () => {
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey when no nodeId is available at all', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
undefined,
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
@@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => {
expect(result).toHaveLength(1)
expect(result[0].hidden).toBe(false)
})
it('collapses duplicate normal widgets on the same node to one render', () => {
const colorA = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const colorB = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'ColorToRGBInt',
widgets: [colorA, colorB],
title: 'Color to RGB Int',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('color')
expect(result[0].renderKey).toBe('node:1:color:color:color')
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {

View File

@@ -129,11 +129,15 @@ export function getWidgetIdentity(
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const hostNodeIdRoot =
nodeId !== undefined && nodeId !== ''
? `node:${String(stripGraphPrefix(nodeId))}`
: undefined
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
: hostNodeIdRoot
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`

View File

@@ -28,6 +28,7 @@ const textMap: Record<ControlOptions, string | null> = {
<template>
<button
data-testid="value-control"
type="button"
:aria-label="t('widgets.valueControl.' + mode)"
:class="

View File

@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IColorWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
function createMockNode(): LGraphNode {
const widgets: IColorWidget[] = []
const addWidget = vi.fn(
(
type: string,
name: string,
value: string,
_callback: () => void,
options: IWidgetOptions
) => {
const widget = {
type,
name,
value,
options,
callback: _callback
} as unknown as IColorWidget
widgets.push(widget)
return widget
}
)
return { widgets, addWidget } as unknown as LGraphNode
}
const colorSpec: InputSpec = {
type: 'COLOR',
name: 'color',
default: '#ffffff',
socketless: true
}
describe('useColorWidget', () => {
it('reads the top-level default from the V2 spec', () => {
const node = createMockNode()
const widget = useColorWidget()(node, colorSpec)
expect(widget.value).toBe('#ffffff')
})
it('falls back to nested options.default when top-level default is absent', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color',
options: { default: '#abcdef' }
} as InputSpec)
expect(widget.value).toBe('#abcdef')
})
it('falls back to #000000 when no default is declared', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color'
} as InputSpec)
expect(widget.value).toBe('#000000')
})
it('returns the existing widget instead of creating a duplicate', () => {
const node = createMockNode()
const first = useColorWidget()(node, colorSpec)
const second = useColorWidget()(node, colorSpec)
expect(second).toBe(first)
expect(node.widgets).toHaveLength(1)
})
})

View File

@@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
const { name, options } = inputSpec as ColorInputSpec
const defaultValue = options?.default || '#000000'
const colorSpec = inputSpec as ColorInputSpec
const { name, options } = colorSpec
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
const existing = node.widgets?.find(
(w): w is IColorWidget => w.name === name && w.type === 'color'
)
if (existing) return existing
const widget = node.addWidget('color', name, defaultValue, () => {}, {
serialize: true

View File

@@ -2,12 +2,13 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { ComfyApp } from './app'
import { createNode } from '@/utils/litegraphUtil'
import {
@@ -20,14 +21,30 @@ import {
} from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { api } from '@/scripts/api'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { NodeError } from '@/schemas/apiSchema'
const {
mockApiKeyAuthStore,
mockAuthStore,
mockSettingStore,
mockToastStore,
mockExtensionService,
mockNodeOutputStore,
mockWorkspaceWorkflow,
mockRefreshMissingModelPipeline
} = vi.hoisted(() => ({
mockApiKeyAuthStore: {
getApiKey: vi.fn()
},
mockAuthStore: {
getAuthToken: vi.fn()
},
mockSettingStore: {
get: vi.fn()
},
mockToastStore: {
addAlert: vi.fn(),
add: vi.fn(),
@@ -41,7 +58,7 @@ const {
refreshNodeOutputs: vi.fn()
},
mockWorkspaceWorkflow: {
activeWorkflow: null
activeWorkflow: null as ComfyWorkflow | null
},
mockRefreshMissingModelPipeline: vi.fn()
}))
@@ -55,6 +72,18 @@ vi.mock('@/utils/litegraphUtil', () => ({
fixLinkInputSlots: vi.fn()
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: vi.fn(() => mockApiKeyAuthStore)
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => mockSettingStore)
}))
vi.mock('@/composables/usePaste', () => ({
pasteAudioNode: vi.fn(),
pasteAudioNodes: vi.fn(),
@@ -110,6 +139,7 @@ function createMockCanvas(): Partial<LGraphCanvas> {
return {
graph: mockGraph as LGraph,
draw: vi.fn(),
selectItems: vi.fn()
}
}
@@ -141,8 +171,70 @@ describe('ComfyApp', () => {
app = new ComfyApp()
mockCanvas = createMockCanvas() as LGraphCanvas
app.canvas = mockCanvas as LGraphCanvas
mockWorkspaceWorkflow.activeWorkflow = null
mockApiKeyAuthStore.getApiKey.mockReturnValue(undefined)
mockAuthStore.getAuthToken.mockResolvedValue(undefined)
mockExtensionService.invokeExtensions.mockReturnValue([])
mockExtensionService.invokeExtensionsAsync.mockResolvedValue(undefined)
mockSettingStore.get.mockImplementation((key: string) =>
key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined
)
})
describe('queuePrompt', () => {
it('shows the error overlay for successful prompt responses with node errors', async () => {
const graph = new LGraph()
const workflow = new ComfyWorkflow({
path: 'workflows/review.json',
modified: 0,
size: 0
})
const promptOutput: ComfyApiWorkflow = {
'1': {
class_type: 'PreviewAny',
inputs: {},
_meta: { title: 'PreviewAny' }
}
}
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'PreviewAny',
dependent_outputs: ['1'],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: source',
details: '',
extra_info: { input_name: 'source' }
}
]
}
}
Reflect.set(app, 'rootGraphInternal', graph)
mockWorkspaceWorkflow.activeWorkflow = workflow
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
output: promptOutput,
workflow: createWorkflowGraphData()
})
vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true)
vi.spyOn(api, 'queuePrompt').mockResolvedValue({
prompt_id: 'job-1',
node_errors: nodeErrors,
error: ''
})
await expect(app.queuePrompt(0)).resolves.toBe(false)
const errorStore = useExecutionErrorStore()
const executionStore = useExecutionStore()
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
expect(errorStore.isErrorOverlayOpen).toBe(true)
expect(executionStore.queuedJobs['job-1']?.nodes).toEqual({ '1': false })
expect(executionStore.jobIdToSessionWorkflowPath.get('job-1')).toBe(
'workflows/review.json'
)
expect(mockCanvas.draw).toHaveBeenCalledWith(true, true)
})
})
describe('refreshComboInNodes', () => {

View File

@@ -1623,20 +1623,32 @@ export class ComfyApp {
})
delete api.authToken
delete api.apiKey
executionErrorStore.lastNodeErrors = res.node_errors ?? null
if (executionErrorStore.lastNodeErrors?.length) {
const nodeErrors = res.node_errors
const hasNodeErrors =
nodeErrors && Object.keys(nodeErrors).length > 0
executionErrorStore.lastNodeErrors = hasNodeErrors
? nodeErrors
: null
try {
if (res.prompt_id) {
executionStore.storeJob({
id: res.prompt_id,
nodes: Object.keys(p.output),
promptOutput: p.output,
workflow: queuedWorkflow
})
}
} catch (error) {
console.warn('Failed to store queued job metadata', {
promptId: res.prompt_id,
error
})
}
if (hasNodeErrors) {
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionErrorStore.showErrorOverlay()
}
this.canvas.draw(true, true)
} else {
try {
if (res.prompt_id) {
executionStore.storeJob({
id: res.prompt_id,
nodes: Object.keys(p.output),
promptOutput: p.output,
workflow: queuedWorkflow
})
}
} catch (error) {}
}
} catch (error: unknown) {
if (

View File

@@ -1,6 +1,98 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isPLYAsciiFormat, parseASCIIPLY } from '@/scripts/metadata/ply'
// Override the global @sparkjsdev/spark mock from vitest.setup.ts (the real
// PlyReader pulls in WASM that doesn't run under Node) with a thin stub
// driven by per-test fixtures — see `mockNextParseHeader` below. Keeping the
// PLY-format parsing out of the test file because (a) it would be a parallel
// implementation that can drift from sparkjs, and (b) sparkjs's PlyReader
// already has its own coverage. We're only testing what isGaussianSplatPLY
// does with the parsed result.
type StubProperty = { isList: boolean; type: string }
type StubElement = {
name: string
count: number
properties: Record<string, StubProperty>
}
const {
nextHeaderResultMock,
resetParseHeaderMock,
mockNextParseHeader,
mockNextParseHeaderError
} = vi.hoisted(() => {
let next: { elements?: Record<string, StubElement>; error?: Error } = {}
return {
nextHeaderResultMock: () => next,
resetParseHeaderMock: () => {
next = {}
},
mockNextParseHeader: (elements: Record<string, StubElement>) => {
next = { elements }
},
mockNextParseHeaderError: (error: Error) => {
next = { error }
}
}
})
vi.mock('@sparkjsdev/spark', () => ({
PlyReader: class {
elements: Record<string, StubElement> = {}
constructor(_: unknown) {}
parseHeader(): Promise<void> {
const { elements, error } = nextHeaderResultMock()
if (error) return Promise.reject(error)
this.elements = elements ?? {}
return Promise.resolve()
}
}
}))
import {
isGaussianSplatPLY,
isPLYAsciiFormat,
parseASCIIPLY
} from '@/scripts/metadata/ply'
const FLOAT = { isList: false, type: 'float' }
const UCHAR = { isList: false, type: 'uchar' }
const vertex = (
props: Record<string, StubProperty>
): Record<string, StubElement> => ({
vertex: { name: 'vertex', count: 1, properties: props }
})
const GAUSSIAN_SPLAT_PROPS = {
x: FLOAT,
y: FLOAT,
z: FLOAT,
f_dc_0: FLOAT,
f_dc_1: FLOAT,
f_dc_2: FLOAT,
opacity: FLOAT,
scale_0: FLOAT,
scale_1: FLOAT,
scale_2: FLOAT,
rot_0: FLOAT,
rot_1: FLOAT,
rot_2: FLOAT,
rot_3: FLOAT
}
const POINT_CLOUD_PROPS = {
x: FLOAT,
y: FLOAT,
z: FLOAT,
red: UCHAR,
green: UCHAR,
blue: UCHAR
}
const DC_ONLY_PROPS = {
x: FLOAT,
y: FLOAT,
z: FLOAT,
f_dc_0: FLOAT,
f_dc_1: FLOAT,
f_dc_2: FLOAT
}
function createPLYBuffer(content: string): ArrayBuffer {
return new TextEncoder().encode(content).buffer
@@ -53,6 +145,35 @@ end_header`
})
})
describe('isGaussianSplatPLY', () => {
beforeEach(resetParseHeaderMock)
it('detects a 3DGS PLY by scale_0..2 + rot_0..3 properties on the vertex element', async () => {
mockNextParseHeader(vertex(GAUSSIAN_SPLAT_PROPS))
expect(await isGaussianSplatPLY(new ArrayBuffer(0))).toBe(true)
})
it('returns false for a point-cloud PLY with no scale/rot properties', async () => {
mockNextParseHeader(vertex(POINT_CLOUD_PROPS))
expect(await isGaussianSplatPLY(new ArrayBuffer(0))).toBe(false)
})
it('returns false when only the f_dc_* DC term is present (no scale/rot)', async () => {
mockNextParseHeader(vertex(DC_ONLY_PROPS))
expect(await isGaussianSplatPLY(new ArrayBuffer(0))).toBe(false)
})
it('returns false when parseHeader rejects (malformed / unsupported PLY)', async () => {
mockNextParseHeaderError(new Error('Failed to read header'))
expect(await isGaussianSplatPLY(new ArrayBuffer(0))).toBe(false)
})
it('returns false when the vertex element is missing entirely', async () => {
mockNextParseHeader({})
expect(await isGaussianSplatPLY(new ArrayBuffer(0))).toBe(false)
})
})
describe('parseASCIIPLY', () => {
it('should parse simple PLY with positions only', () => {
const ply = `ply

View File

@@ -2,6 +2,7 @@
* PLY (Polygon File Format) decoder
* Parses ASCII PLY files and extracts vertex positions and colors
*/
import { PlyReader } from '@sparkjsdev/spark'
interface PLYHeader {
vertexCount: number
@@ -151,3 +152,27 @@ export function isPLYAsciiFormat(arrayBuffer: ArrayBuffer): boolean {
const header = new TextDecoder().decode(arrayBuffer.slice(0, 500))
return header.includes('format ascii')
}
/**
* Mirrors sparkjs's own check (PlyReader.parseSplats:
* `hasScales && hasRots`), we delegate header parsing to sparkjs's
* PlyReader so the property dictionary we inspect is exactly the one
* sparkjs would see if asked to render the file. parseHeader rejects
* ASCII PLYs by design; we catch and treat them as not-3DGS (no real
* 3DGS export uses ASCII PLY).
*/
export async function isGaussianSplatPLY(
arrayBuffer: ArrayBuffer
): Promise<boolean> {
try {
const reader = new PlyReader({ fileBytes: arrayBuffer })
await reader.parseHeader()
const props = reader.elements.vertex?.properties
if (!props) return false
const hasScales = !!(props.scale_0 && props.scale_1 && props.scale_2)
const hasRots = !!(props.rot_0 && props.rot_1 && props.rot_2 && props.rot_3)
return hasScales && hasRots
} catch {
return false
}
}

View File

@@ -52,7 +52,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
isErrorOverlayOpen.value = false
}
/** Clear all error state. Called at execution start and workflow changes.
/** Clear all error state.
* Missing model state is intentionally preserved here to avoid wiping
* in-progress model repairs (importTaskIds, URL inputs, etc.).
* Missing models are cleared separately during workflow load/clean paths. */
@@ -64,6 +64,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
isErrorOverlayOpen.value = false
}
function clearExecutionStartErrors() {
lastExecutionError.value = null
lastPromptError.value = null
if (
!lastNodeErrors.value ||
Object.keys(lastNodeErrors.value).length === 0
) {
isErrorOverlayOpen.value = false
}
}
/** Clear only prompt-level errors. Called during resetExecutionState. */
function clearPromptError() {
lastPromptError.value = null
@@ -361,6 +372,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Clearing
clearAllErrors,
clearExecutionStartErrors,
clearPromptError,
// Overlay UI

View File

@@ -948,6 +948,48 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.queuedJobs['job-1']).toEqual({ nodes: {} })
})
it('clears transient errors while preserving validation errors', () => {
const errorStore = useExecutionErrorStore()
const nodeErrors = {
'1': {
class_type: 'Test',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
]
}
}
errorStore.lastExecutionError = {
prompt_id: 'old-job',
timestamp: 0,
node_id: '1',
node_type: 'Test',
executed: [],
exception_message: 'boom',
exception_type: 'RuntimeError',
traceback: []
}
errorStore.lastPromptError = {
type: 'old-error',
message: 'old prompt error',
details: ''
}
errorStore.lastNodeErrors = nodeErrors
errorStore.showErrorOverlay()
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
expect(errorStore.lastExecutionError).toBeNull()
expect(errorStore.lastPromptError).toBeNull()
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
expect(errorStore.isErrorOverlayOpen).toBe(true)
})
it('clears initializing state for the starting job', () => {
store.initializingJobIds = new Set([
'job-1',

View File

@@ -261,7 +261,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
executionIdToLocatorCache.clear()
executionErrorStore.clearAllErrors()
executionErrorStore.clearExecutionStartErrors()
activeJobId.value = e.detail.prompt_id
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
clearInitializationByJobId(activeJobId.value)

View File

@@ -473,6 +473,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
node.imgs = [element]
node.imageIndex = activeIndex
const outputs = getNodeOutputs(node)
if (outputs?.images) node.images = outputs.images
}
return {