Compare commits

...

27 Commits

Author SHA1 Message Date
Comfy Org PR Bot
383760e728 [backport cloud/1.45] feat(telemetry): capture desktop entry props in cloud build (#12649)
Backport of #12647 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-04 13:04:14 -07:00
Comfy Org PR Bot
af8f0b60f5 1.45.15 (#12651)
Patch version increment to 1.45.15

**Base branch:** `cloud/1.45`

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-06-04 12:49:05 -07:00
AustinMroz
434a1b1af1 [backport cloud/1.45] refactor(assets): read content hash from the canonical hash field (#12650)
Backport of #12638 to `cloud/1.45`

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-04 12:48:47 -07:00
Comfy Org PR Bot
bd6e5e2286 [backport cloud/1.45] feat: add app:node_added telemetry event (#12641)
Backport of #12615 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 08:56:04 -07:00
Comfy Org PR Bot
6a78e0b635 [backport cloud/1.45] fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#12640)
Backport of #11716 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-04 11:31:02 +09:00
Comfy Org PR Bot
b751750f0b [backport cloud/1.45] feat(telemetry): capture Rewardful referral on checkout attribution (#12625)
Backport of #12311 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-03 11:59:59 -07:00
Comfy Org PR Bot
2fa47fa260 [backport cloud/1.45] feat: add missing_node_packs to app:workflow_imported telemetry (#12616)
Backport of #12613 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:47:50 -07:00
AustinMroz
505728cc56 [backport cloud/1.45] Feat/cloud onboarding redesign (#12610)
Backport of #12422 to `cloud/1.45`

Co-authored-by: Maanil Verma <vermaMaanil97@gmail.com>
Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-02 17:14:29 -07:00
Comfy Org PR Bot
880af41f34 [backport cloud/1.45] refactor(assets): read content hash via hash field, fall back to asset_hash (#12612)
Backport of #12609 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-02 16:52:54 -07:00
Terry Jia
085bef657b [backport cloud/1.45] feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12597)
Backport https://github.com/Comfy-Org/ComfyUI_frontend/pull/12545 to
cloud/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:46:26 -07:00
Comfy Org PR Bot
f849e9be77 [backport cloud/1.45] Pr/12481 - fixed error (#12604)
Backport of #12574 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Steven Tran <94876858+stevenltran@users.noreply.github.com>
Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-02 11:01:39 -07:00
Comfy Org PR Bot
bd48bf1bbe [backport cloud/1.45] Updated Pr 12480 - fix(telemetry): call posthog.reset(true) on logout to prevent session bleeding (#12606)
Backport of #12599 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Steven Tran <94876858+stevenltran@users.noreply.github.com>
Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
2026-06-02 11:01:31 -07:00
Comfy Org PR Bot
75fb11785a [backport cloud/1.45] fix: dedupe Bypass context-menu items via state-aware legacy label (FE-720) (#12587)
Backport of #12500 to `cloud/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:17 +09:00
Comfy Org PR Bot
5ff0b33295 [backport cloud/1.45] Track undo state on subgraph conversion (#12585)
Backport of #12575 to `cloud/1.45`

Automatically created by backport workflow.

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

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-01 18:57:16 -07:00
Comfy Org PR Bot
6f3ef2ed70 [backport cloud/1.45] fix(cloud/oauth): mint session cookie when resuming consent while already signed in (#12577)
Backport of #12571 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-01 15:34:09 -07:00
Comfy Org PR Bot
cd216d4db8 [backport cloud/1.45] fix(telemetry): harden PostHog init — person_profiles, cookie_domain, before_send (#12573)
Backport of #12479 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miles <miles@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Miles Ryan <thedatalife@users.noreply.github.com>
2026-06-01 14:53:21 -07:00
Comfy Org PR Bot
bccfc41f5d [backport cloud/1.45] fix: preserve validation errors on execution start (#12548)
Backport of #12493 to `cloud/1.45`

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 11:39:14 -07:00
Comfy Org PR Bot
28c4080134 [backport cloud/1.45] Fix mask editor sometimes showing wrong image (#12484)
Backport of #12413 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:47:08 -07:00
Comfy Org PR Bot
1a6e77e955 [backport cloud/1.45] Fix errant subscription popups with workspaces (#12476)
Backport of #12472 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-26 19:42:39 -07:00
Comfy Org PR Bot
e06d7a7b34 [backport cloud/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12454)
Backport of #12447 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 21:52:32 -07:00
Comfy Org PR Bot
c76b7280af [backport cloud/1.45] Fix missing value control on 'Primitive Int' (#12462)
Backport of #12431 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:22:28 -07:00
166 changed files with 4710 additions and 741 deletions

View File

@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
mockInit: vi.fn(),
mockCapture: vi.fn()
}))
vi.mock('posthog-js', () => ({
default: {
init: hoisted.mockInit,
capture: hoisted.mockCapture
}
}))
describe('initPostHog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
const { initPostHog } = await import('./posthog')
initPostHog()
expect(hoisted.mockInit).toHaveBeenCalledOnce()
const initOptions = hoisted.mockInit.mock.calls[0][1]
expect(initOptions.person_profiles).toBe('identified_only')
expect(typeof initOptions.before_send).toBe('function')
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: { email: 'd@example.com', name: 'keep me' },
$set_once: { $email: 'e@example.com', plan: 'free' }
}
const result = initOptions.before_send(event)
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('$email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})

View File

@@ -1,5 +1,7 @@
import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -18,7 +20,9 @@ export function initPostHog() {
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
person_profiles: 'identified_only',
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
before_send: createPostHogBeforeSend()
})
initialized = true
} catch (error) {

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

@@ -1,10 +1,11 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-model-001',
name: 'model.safetensors',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
function createInputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-input-001',
name: 'input.png',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
function createOutputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-output-001',
name: 'output_00001.png',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],

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

@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
in_library: false
}
const defaultInputAsset: Asset = {
const defaultInputAsset: Asset & { hash?: string } = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
const importedInputAsset: Asset & { hash?: string } = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['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

@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset = {
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],

View File

@@ -24,10 +24,10 @@ const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const cloudOutputAsset: Asset = {
const cloudOutputAsset: Asset & { hash?: string } = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -36,10 +36,10 @@ const cloudOutputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -50,10 +50,10 @@ const cloudUploadedVideoAsset: Asset = {
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

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

14
global.d.ts vendored
View File

@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
interface RewardfulGlobal {
referral?: string
affiliate?: { id?: string; token?: string; name?: string }
campaign?: { id?: string; name?: string }
}
interface RewardfulQueueFunction {
(method: 'ready', callback: () => void): void
(...args: unknown[]): void
q?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
@@ -63,6 +75,8 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

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

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@@ -25,6 +25,7 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
@@ -65,6 +66,9 @@
--color-ocean-600: #2f687a;
--color-ocean-900: #253236;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-canvas: #c2bfb9;
--color-danger-100: #c02323;
--color-danger-200: #d62952;

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -7,7 +7,8 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts"
"./networkUtil": "./src/networkUtil.ts",
"./piiUtil": "./src/piiUtil.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { createPostHogBeforeSend } from './piiUtil'
describe('createPostHogBeforeSend', () => {
const beforeSend = createPostHogBeforeSend()
it('returns null for null input', () => {
expect(beforeSend(null)).toBeNull()
})
it('strips all PII keys from properties, $set, and $set_once', () => {
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: {
email: 'd@example.com',
user_email: 'e@example.com',
$email: 'f@example.com',
name: 'keep me'
},
$set_once: {
email: 'g@example.com',
plan: 'free'
}
}
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('handles missing property bags gracefully', () => {
const event = { properties: { email: 'a@example.com', safe: true } }
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).toHaveProperty('safe', true)
expect(result.$set).toBeUndefined()
expect(result.$set_once).toBeUndefined()
})
})

View File

@@ -0,0 +1,35 @@
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
function stripPiiKeys(obj?: Record<string, unknown>): void {
if (!obj) return
for (const key of PII_KEYS) {
delete obj[key]
}
}
/**
* PostHog before_send hook that strips PII from all three property bags
* an event can carry: properties, $set, and $set_once.
*
* posthog.identify(id, { email }) lands in $set, not properties, so all
* three bags must be sanitized.
*
* Ref: posthog.com/tutorials/web-redact-properties
*/
interface PostHogEventLike {
properties?: Record<string, unknown>
$set?: Record<string, unknown>
$set_once?: Record<string, unknown>
}
export function createPostHogBeforeSend() {
return function beforeSend<E extends PostHogEventLike>(
event: E | null
): E | null {
if (!event) return null
stripPiiKeys(event.properties)
stripPiiKeys(event.$set)
stripPiiKeys(event.$set_once)
return event
}
}

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

@@ -66,6 +66,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
@@ -130,10 +131,12 @@ const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
)
if (!node) return

View File

@@ -65,6 +65,7 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
@@ -155,8 +156,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = useLitegraphService().addNodeOnGraph(
provider.nodeDef
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key

View File

@@ -189,6 +189,7 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import {
DEFAULT_GROUPING_ID,
@@ -321,8 +322,11 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
handleClick(e: MouseEvent) {
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
} else {
toggleNodeOnEvent(e, this)
}

View File

@@ -39,6 +39,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -183,8 +184,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
await nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
} else {
toggleNodeOnEvent(e, node)
}

View File

@@ -1,3 +1,4 @@
import { FirebaseError } from 'firebase/app'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -26,9 +27,20 @@ const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
])
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key
values?.workflow ? `${key}:${values.workflow}` : key,
st: (key: string, fallback: string) => {
const code = key.replace('auth.errors.', '')
return knownAuthErrorCodes.has(code) ? key : fallback
}
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -72,7 +84,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: vi.fn()
toastErrorHandler: mockToastErrorHandler
})
}))
@@ -193,3 +205,46 @@ describe('useAuthActions.logout', () => {
)
})
})
describe('useAuthActions.reportError', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('shows the friendly message for a known Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.auth/invalid-credential'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('shows the generic fallback for an unknown Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.generic'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('delegates non-Firebase errors to toastErrorHandler', () => {
const { reportError } = useAuthActions()
const networkError = new TypeError('Failed to fetch')
reportError(networkError)
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,7 @@ import { ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { st, t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -47,6 +47,12 @@ export const useAuthActions = () => {
email: 'support@comfy.org'
})
})
} else if (error instanceof FirebaseError) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
})
} else {
toastErrorHandler(error)
}

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

@@ -1,5 +1,6 @@
import { ref, shallowRef } from 'vue'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -37,7 +38,8 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const nodeDef = draggedNode.value
if (!nodeDef) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
@@ -46,7 +48,9 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
const node = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
if (node) canvas.selectItems([node])
return true
}

View File

@@ -8,6 +8,7 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
@@ -146,9 +147,11 @@ export function useJobMenu(
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
if (!node) return

View File

@@ -4,6 +4,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app as comfyApp } from '@/scripts/app'
@@ -37,7 +38,9 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(nodeDef, { pos })
)
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = basePos
@@ -58,11 +61,8 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
)
targetProvider = provider
}

View File

@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
@@ -107,7 +108,8 @@ export function useFeatureFlags() {
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??

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

@@ -2278,7 +2278,8 @@
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
"auth/network-request-failed": "Network error. Please check your connection and try again.",
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again.",
"generic": "Something went wrong while signing you in. Please try again."
},
"deleteAccount": {
"contactSupport": "To delete your account, please contact {email}"
@@ -2949,6 +2950,29 @@
"cloudStart_learnAboutButton": "Learn about Cloud",
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
"cloudStart_download": "Download ComfyUI",
"cloudHero": {
"previousSlide": "Previous slide",
"nextSlide": "Next slide",
"slidePagerLabel": "Go to slide {index}",
"slides": {
"cloud": {
"title": "Cloud",
"description": "Best for most users who want to work from anywhere with models verified for commercial license."
},
"workflows": {
"title": "Workflows",
"description": "From idea to output in minutes. Generate multiple variations side by side."
},
"team": {
"title": "Team",
"description": "Onboard your team today. Share workflows and assets across your organization."
},
"models": {
"title": "Models",
"description": "Curated, commercially licensed models ready to run with zero setup."
}
}
},
"cloudWaitlist_questionsText": "Questions? Contact us",
"cloudWaitlist_contactLink": "here",
"cloudSorryContactSupport_title": "Sorry, contact support",

View File

@@ -176,7 +176,7 @@ describe('AssetBrowserModal', () => {
): AssetItem => ({
id,
name,
asset_hash: `blake3:${id.padEnd(64, '0')}`,
hash: `blake3:${id.padEnd(64, '0')}`,
size: 1024000,
mime_type: 'application/octet-stream',
tags: ['models', category, 'test'],

View File

@@ -49,10 +49,10 @@ const ORIGINAL_FILENAME = 'sunset_photo.png'
function createDisplayAsset(
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem {
return {
const base = {
id: 'asset-1',
name: HASH,
asset_hash: HASH,
hash: HASH,
tags: ['input'],
preview_url: '/preview.png',
secondaryText: '',
@@ -62,6 +62,7 @@ function createDisplayAsset(
metadata: { filename: ORIGINAL_FILENAME },
...overrides
}
return base
}
function renderCard(asset: AssetDisplayItem) {
@@ -97,7 +98,7 @@ describe('AssetCard', () => {
})
describe('FE-228: filename rendering', () => {
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
it('renders the human-readable filename instead of hash when asset.name equals hash', () => {
const asset = createDisplayAsset()
renderCard(asset)
@@ -130,7 +131,7 @@ describe('AssetCard', () => {
const asset = createDisplayAsset({
id: 'model-1',
name: MODEL_FILENAME,
asset_hash: undefined,
hash: undefined,
tags: ['models', 'loras'],
user_metadata: { name: CURATED_NAME },
metadata: { filename: MODEL_FILENAME }
@@ -146,7 +147,7 @@ describe('AssetCard', () => {
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
const asset = createDisplayAsset({
name: HASH,
asset_hash: HASH,
hash: HASH,
user_metadata: { name: HASH },
metadata: { filename: ORIGINAL_FILENAME }
})

View File

@@ -32,7 +32,7 @@ function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'mesh.glb',
asset_hash: null,
hash: null,
mime_type: 'model/gltf-binary',
tags: [],
kind: '3D',

View File

@@ -13,7 +13,7 @@ function createVideoAsset(
return {
id: 'video-1',
name: 'clip.mp4',
asset_hash: null,
hash: null,
mime_type: mimeType,
tags: [],
kind: 'video',

View File

@@ -28,7 +28,7 @@ describe('ModelInfoPanel', () => {
): AssetDisplayItem => ({
id: 'test-id',
name: 'test-model.safetensors',
asset_hash: 'hash123',
hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

View File

@@ -26,7 +26,7 @@ function makeAsset(index: number): AssetItem {
return {
id: `asset-${index}`,
name: `asset-${index}.safetensors`,
asset_hash: `blake3:${index}`,
hash: `blake3:${index}`,
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', category],

View File

@@ -35,7 +35,7 @@ describe('useAssetBrowser', () => {
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
name: 'test-asset.safetensors',
asset_hash: 'blake3:abc123',
hash: 'blake3:abc123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

View File

@@ -296,7 +296,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
name: 'my-image.jpeg',
asset_hash: 'hash123.jpeg'
hash: 'hash123.jpeg'
})
await actions.addWorkflow(asset)
@@ -310,12 +310,12 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use asset_hash as filename when available', async () => {
it('should use hash as filename when available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'original.jpeg',
asset_hash: 'abc123hash.jpeg'
hash: 'abc123hash.jpeg'
})
await actions.addWorkflow(asset)
@@ -323,12 +323,12 @@ describe('useMediaAssetActions', () => {
expect(capturedFilenames.values).toContain('abc123hash.jpeg')
})
it('should fall back to asset.name when asset_hash is not available', async () => {
it('should fall back to asset.name when hash is not available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-name.jpeg',
asset_hash: undefined
hash: undefined
})
await actions.addWorkflow(asset)
@@ -336,12 +336,12 @@ describe('useMediaAssetActions', () => {
expect(capturedFilenames.values).toContain('fallback-name.jpeg')
})
it('should fall back to asset.name when asset_hash is null', async () => {
it('should fall back to asset.name when hash is null', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-null.jpeg',
asset_hash: null
hash: null
})
await actions.addWorkflow(asset)
@@ -357,19 +357,19 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use asset_hash for each asset', async () => {
it('should use hash for each asset', async () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({
id: '1',
name: 'file1.jpeg',
asset_hash: 'hash1.jpeg'
hash: 'hash1.jpeg'
}),
createMockAsset({
id: '2',
name: 'file2.jpeg',
asset_hash: 'hash2.jpeg'
hash: 'hash2.jpeg'
})
]
@@ -973,7 +973,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-match',
name: 'foo.png',
asset_hash: 'abc123.png',
hash: 'abc123.png',
tags: ['input']
})
@@ -1051,7 +1051,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-failed',
name: 'failed.png',
asset_hash: 'failhash.png'
hash: 'failhash.png'
})
await actions.deleteAssets(asset)

View File

@@ -6,6 +6,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
@@ -42,8 +43,8 @@ const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
*
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
* present in metadata). Input/temp assets emit the bare name plus the explicit
* annotation. `asset_hash` is included whenever present, since cloud-stored
* assets can be referenced by hash.
* annotation. The content `hash` is included whenever present, since
* cloud-stored assets can be referenced by hash.
*/
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
const variants: string[] = []
@@ -61,7 +62,8 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
variants.push(`${name} [input]`)
}
}
if (asset.asset_hash) variants.push(asset.asset_hash)
const hash = asset.hash
if (hash) variants.push(hash)
return variants
}
@@ -279,9 +281,11 @@ export function useMediaAssetActions() {
return
}
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
if (!node) {
toast.add({
@@ -296,12 +300,10 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && targetAsset.asset_hash
? targetAsset.asset_hash
: targetAsset.name
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = targetAsset.hash
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
// Create annotated path for the asset
const annotated = createAnnotatedPath(
@@ -425,12 +427,14 @@ export function useMediaAssetActions() {
}
const center = litegraphService.getCanvasCenter()
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
)
if (!node) {
failed++
@@ -440,10 +444,10 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = asset.hash
const filename = isCloud && cloudHash ? cloudHash : asset.name
const annotated = createAnnotatedPath(
{

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