Compare commits

..

39 Commits

Author SHA1 Message Date
comfy-pr-bot
3acb76700d [release] Increment version to 1.45.19 2026-06-19 05:07:09 +00:00
Comfy Org PR Bot
22df38e44a 1.45.18 (#12989)
Patch version increment to 1.45.18

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

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-06-18 22:05:03 -07:00
Comfy Org PR Bot
82bff4e198 1.45.17 (#12988)
Patch version increment to 1.45.17

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-06-18 21:21:21 -07:00
AustinMroz
125072d074 [backport core/1.45] Support opus format in assets panel (#12981)
Manual backport of #12956 to `core/1.45`
2026-06-18 19:14:32 -07:00
Comfy Org PR Bot
b980be6876 [backport core/1.45] feat(load3d) FE-999: export point cloud / splat files as-is (#12970)
Backport of #12810 to `core/1.45`

Automatically created by backport workflow.

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-06-18 18:00:56 -07:00
Christian Byrne
89ea30244a [backport core/1.45] Update default workflow (#12966)
Backport of #12804 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-18 16:47:47 -07:00
Comfy Org PR Bot
1f0888bd12 [backport core/1.45] feat: Load3DAdvanced uploads to input/3d (#12892)
Backport of #12851 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-18 14:47:28 -07:00
Comfy Org PR Bot
a78c984753 [backport core/1.45] test: harden assets media-type filter spec against VirtualGrid flake (#12936)
Backport of #12897 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 13:45:10 -07:00
Comfy Org PR Bot
ccea9a2a19 [backport core/1.45] feat: add Load3DAdvanced node (#12890)
Backport of #12723 to `core/1.45`

Automatically created by backport workflow.

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-06-17 16:00:03 -07:00
Comfy Org PR Bot
6d8fa1bef8 [backport core/1.45] fix: encode large copy payload metadata in chunks (#12910)
Backport of #12847 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:51:25 +09:00
Comfy Org PR Bot
52c8925a80 [backport core/1.45] Fix undated failed runs in job history grouping (#12902)
Backport of #12879 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:03:42 +09:00
Comfy Org PR Bot
ce5287dce6 [backport core/1.45] fix: bind replacement node widgets to reused id (#12905)
Backport of #12872 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-17 16:03:09 +09:00
Comfy Org PR Bot
a15e9029b3 [backport core/1.45] FE-905 fix(load3d): cache scene capture so unchanged runs hit backend cache (#12680)
Backport of #12627 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-16 15:22:40 -07:00
AustinMroz
e4ff68773e [backport core/1.45] Resolve errant executionIds on workflow restore (#12869)
Manual backport of #12659 to `core/1.45`
2026-06-16 15:13:14 -07:00
Comfy Org PR Bot
b5f0a7669e [backport core/1.45] fix: stop Add Secret dialog rendering behind Settings modal (FE-939) (#12686)
Backport of #12665 to `core/1.45`

Automatically created by backport workflow.

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-13 10:04:50 +09:00
Comfy Org PR Bot
9fe57ef2ab [backport core/1.45] Bumping search ranks (#12757)
Backport of #12750 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-10 01:17:34 -07:00
Comfy Org PR Bot
a8b9cfa9a8 1.45.16 (#12718)
Patch version increment to 1.45.16

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

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-06-09 21:47:10 -07:00
Comfy Org PR Bot
9105553958 [backport core/1.45] [bugfix] Use Desktop2 bridge for missing model downloads (#12738)
Backport of #12710 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-10 03:38:37 +09:00
Comfy Org PR Bot
16db78a61a [backport core/1.45] fix: clear missing model on promoted widget change (#12696)
Backport of #12677 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 19:14:55 +09:00
Comfy Org PR Bot
82c87dfbdf [backport core/1.45] fix: defer node auto-pan until drag starts (#12700)
Backport of #12654 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 19:14:23 +09:00
Comfy Org PR Bot
400d0ef9a6 [backport core/1.45] fix: keep connected advanced inputs visible (#12690)
Backport of #12652 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-08 15:53:15 +09:00
Comfy Org PR Bot
2c85e09574 [backport core/1.45] fix(load3d): load Preview3DAdvanced / splat / pointcloud previews from temp/ (#12675)
Backport of #12671 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-05 20:35:00 -04:00
Comfy Org PR Bot
a650cefe30 [backport core/1.45] fix(load3d): load Preview3DAdvanced output from temp/, allow temp loadFolder (#12668)
Backport of #12661 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-05 11:14:50 -04:00
Comfy Org PR Bot
9c67099a9f [backport core/1.45] feat(load3d): register Preview3DAdvanced extension (#12657)
Backport of #12527 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-06-04 19:56:21 -04:00
Comfy Org PR Bot
2d9b1fed64 [backport core/1.45] fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#12639)
Backport of #11716 to `core/1.45`

Automatically created by backport workflow.

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

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

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

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

View File

@@ -109,27 +109,3 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Customer.io references found'

View File

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

View File

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

View File

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

View File

@@ -1,135 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
* working user to /cloud/survey, while a genuine 404 (survey never submitted)
* must still route a not-completed user there. Drives a raw `page` so the cloud
* app boots against fully mocked endpoints (`comfyPage` would reach the OSS
* devtools backend during setup).
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
async function mockSurveyNotCompleted(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ code: 'NOT_FOUND', message: 'Setting not found' })
})
)
}
// Transient auth failure: a stale workspace token makes the authenticated
// survey check 401 — the hiccup that used to bounce working users.
async function mockSurveyTransient401(page: Page) {
await page.route('**/api/settings/onboarding_survey', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
code: 'UNAUTHORIZED',
message: 'User authentication required'
})
})
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyTransient401(page)
await bootCloud(page)
await page.goto(APP_URL)
// The full app boots — CloudSurveyView is a standalone onboarding view, so
// reaching the extension manager proves we landed on the working app and
// the transient 401 was treated as "completed", not a bounce.
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/\/cloud\/survey/)
})
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
await mockSurveyNotCompleted(page)
await bootCloud(page)
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/survey/, { timeout: 45_000 })
})
})

View File

@@ -12,10 +12,11 @@ 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 & { hash?: string } = {
const LOTUS_DIFFUSION_MODEL: Asset = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],

View File

@@ -23,31 +23,11 @@ const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
widgetName: 'image',
serverOnlyOption: 'server-only-image.png',
position: { x: 150, y: 150 }
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
serverOnlyOption: 'server-only-video.mp4',
position: { x: 450, y: 150 }
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
serverOnlyOption: 'server-only-audio.wav',
position: { x: 750, y: 150 }
}
]
const cloudOutputAsset: Asset & { hash?: string } = {
const cloudOutputAsset: Asset = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
hash: outputHash,
asset_hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -56,10 +36,10 @@ const cloudOutputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
const cloudUploadedVideoAsset: Asset = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
hash: plainVideoFileName,
asset_hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -70,10 +50,10 @@ const cloudUploadedVideoAsset: Asset & { hash?: string } = {
// 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 & { hash?: string } = {
const cloudDefaultGraphInputAsset: Asset = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -86,168 +66,12 @@ interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
type ObjectInfoResponse = Record<
string,
{ input?: { required?: Record<string, unknown> } }
>
function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: string[]
) {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!Array.isArray(input)) {
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
const [valuesOrType, options] = input
const optionsObject =
options && typeof options === 'object' && !Array.isArray(options)
if (Array.isArray(valuesOrType)) {
input[0] = values
} else if (valuesOrType !== 'COMBO') {
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
}
if (optionsObject) {
Object.assign(options, { options: values })
} else if (!Array.isArray(valuesOrType)) {
throw new Error(
`Expected ${nodeType}.${inputName} to have options metadata`
)
}
}
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/api/userdata**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
})
await page.route('**/i18n', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
await page.route('**/customers/cloud-subscription-status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ is_active: true })
})
})
}
async function routeSetupObjectInfo(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void
) {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
const objectInfo = (await response.json()) as ObjectInfoResponse
customize?.(objectInfo)
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
}
}
await page.route('**/object_info', objectInfoRouteHandler)
return async () =>
await page.unroute('**/object_info', objectInfoRouteHandler)
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
})
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
const cloudUploadRaceTest = comfyPageFixture.extend<{
markUploadedCloudAssetAvailable: () => void
}>({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false
}
@@ -282,13 +106,9 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
try {
await use(page)
} finally {
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await unrouteObjectInfo()
cloudUploadAssetStateByPage.delete(page)
}
await use(page)
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
cloudUploadAssetStateByPage.delete(page)
},
markUploadedCloudAssetAvailable: async ({ page }, use) => {
await use(() => {
@@ -319,41 +139,7 @@ async function expectNoErrorsTab(comfyPage: ComfyPage) {
).toBeHidden()
}
async function closeTemplatesDialogIfOpen(comfyPage: ComfyPage) {
const templatesDialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.templates.content
})
const closeButton = templatesDialog.getByRole('button', {
name: 'Close dialog'
})
await closeButton
.waitFor({ state: 'visible', timeout: 1_000 })
.catch(() => undefined)
if (await closeButton.isVisible()) {
await closeButton.click()
await expect(templatesDialog).toBeHidden()
}
}
async function getMediaLoaderWidgetValues(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate((nodes) => {
return nodes.map(({ nodeType, widgetName }) => {
const node = window.app!.graph.nodes.find(
(graphNode) => graphNode.type === nodeType
)
const widget = node?.widgets?.find(
(candidate) => candidate.name === widgetName
)
return widget?.value ?? null
})
}, emptyMediaLoaderNodes)
}
async function delayNextUpload(
comfyPage: ComfyPage,
uploadResult?: { name: string; subfolder: string; type: 'input' }
) {
async function delayNextUpload(comfyPage: ComfyPage) {
let releaseUpload!: () => void
let resolveUploadStarted!: () => void
const uploadStarted = new Promise<void>((resolve) => {
@@ -366,14 +152,6 @@ async function delayNextUpload(
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
if (uploadResult) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(uploadResult)
})
return
}
await route.continue()
}
@@ -517,51 +295,12 @@ ossTest.describe(
}
)
cloudEmptyMediaInputsTest.describe(
'Errors tab - Cloud empty media loader inputs',
{ tag: '@cloud' },
() => {
cloudEmptyMediaInputsTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudEmptyMediaInputsTest(
'does not surface missing inputs after adding LoadImage, LoadVideo, and LoadAudio nodes with no cloud input assets',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
for (const node of emptyMediaLoaderNodes) {
await comfyPage.nodeOps.addNode(
node.nodeType,
undefined,
node.position
)
}
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'input')
)
)
.toBe(true)
await expect
.poll(() => getMediaLoaderWidgetValues(comfyPage))
.toEqual(['', '', ''])
await expectNoErrorsTab(comfyPage)
}
)
}
)
cloudOutputTest.describe(
'Errors tab - Cloud missing media runtime sources',
{ tag: '@cloud' },
() => {
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudOutputTest(
@@ -590,18 +329,13 @@ cloudUploadRaceTest.describe(
() => {
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
await closeTemplatesDialogIfOpen(comfyPage)
})
cloudUploadRaceTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage, {
name: plainVideoFileName,
subfolder: '',
type: 'input'
})
const delayedUpload = await delayNextUpload(comfyPage)
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition

View File

@@ -15,6 +15,10 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
//
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
// full store, so the per-filter count assertions still cover the behavior.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
@@ -113,7 +117,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
@@ -136,7 +140,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -153,7 +157,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('video')
@@ -167,7 +171,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('audio')
@@ -179,7 +183,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('3d')
@@ -193,7 +197,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -211,7 +215,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')

View File

@@ -2,7 +2,6 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkflowTemplates } from '@/platform/workflow/templates/types/template'
import { getWav } from '@e2e/fixtures/components/AudioPreview'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -451,57 +450,4 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
)
}
)
test('Can open associated tutorial', async ({ comfyPage }) => {
const tutorialUrl = 'https://comfyanonymous.github.io/ComfyUI_examples/'
await comfyPage.page.route('**/templates/index.json', async (route) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'template-with-tutorial',
title: 'Template with a tutorial',
mediaType: 'audio',
mediaSubtype: 'wav',
description: 'This template has a tutorial',
tutorialUrl
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.page.route('**/templates/**.wav', async (route) => {
await route.fulfill({
status: 200,
body: getWav(),
headers: {
'Content-Type': 'image/x-wav',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const card = comfyPage.page.getByTestId(
'template-workflow-template-with-tutorial'
)
await card.hover()
const tutorialButton = card.getByRole('button', { name: 'See a tutorial' })
await expect(tutorialButton).toBeVisible()
const popupPromise = comfyPage.page.waitForEvent('popup', { timeout: 0 })
await tutorialButton.click()
const popup = await popupPromise
expect(popup.url()).toEqual(tutorialUrl)
})
})

19
global.d.ts vendored
View File

@@ -11,18 +11,6 @@ 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 {
@@ -49,11 +37,6 @@ interface Window {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number
@@ -80,8 +63,6 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

View File

@@ -3,7 +3,6 @@
<head>
<meta charset="UTF-8" />
<title>ComfyUI</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"

View File

@@ -5,16 +5,9 @@
"start_url": "/",
"icons": [
{
"src": "/assets/images/comfy-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/images/comfy-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
"src": "/assets/images/comfy-logo-single.svg",
"sizes": "any",
"type": "image/svg+xml"
}
],
"display": "standalone"

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.15",
"version": "1.45.19",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -67,7 +67,6 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

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,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}]");
@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}]");
/* 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,7 +25,6 @@
@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);
@@ -66,9 +65,6 @@
--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

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

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

View File

@@ -102,6 +102,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('sound.wav')).toBe('audio')
expect(getMediaTypeFromFilename('music.ogg')).toBe('audio')
expect(getMediaTypeFromFilename('audio.flac')).toBe('audio')
expect(getMediaTypeFromFilename('music.opus')).toBe('audio')
})
})
@@ -111,6 +112,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -590,8 +590,16 @@ const IMAGE_EXTENSIONS = [
'svg'
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'opus'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

View File

@@ -1,58 +0,0 @@
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

@@ -1,35 +0,0 @@
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
}
}

129
pnpm-lock.yaml generated
View File

@@ -21,9 +21,6 @@ catalogs:
'@comfyorg/comfyui-electron-types':
specifier: 0.6.2
version: 0.6.2
'@customerio/cdp-analytics-browser':
specifier: ^0.5.3
version: 0.5.4
'@eslint/js':
specifier: ^9.39.1
version: 9.39.1
@@ -439,9 +436,6 @@ importers:
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.4
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0
@@ -1425,15 +1419,6 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
'@customerio/cdp-analytics-browser@0.5.4':
resolution: {integrity: sha512-F2G+7fTXu7f+zXqXLA4ri8bEuwoxtB64Pjh2Mt/vvuZfT/PlpvP1bz550s7SPH3qzeSuOeVKny27Xc795cZCJg==}
'@customerio/cdp-analytics-core@0.5.4':
resolution: {integrity: sha512-JQcqfKHkXuCgrXHHUofxHVBF/0wpeEwmWItbSv8Kt1mVMcMKcVWBULc2HTbYDJvaad4tW+66A7JApL+lD1hZJQ==}
'@customerio/jist@0.1.8':
resolution: {integrity: sha512-MPiAm5rxu6+wQiEPwY+nV/5i7y67vJ0TvQpeQrOuATzWC45kgpu4YAJm+RlrpDOq35CK1C3utlPG/wI1F6ycXg==}
'@cyberalien/svg-utils@1.1.1':
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
@@ -2370,14 +2355,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@lukeed/uuid@2.0.1':
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
engines: {node: '>=8'}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -3277,18 +3254,6 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@segment/analytics.js-video-plugins@0.2.1':
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
'@segment/facade@3.4.10':
resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==}
'@segment/isodate-traverse@1.1.1':
resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==}
'@segment/isodate@1.0.3':
resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==}
'@sentry-internal/browser-utils@10.32.1':
resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==}
engines: {node: '>=18'}
@@ -5077,9 +5042,6 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
customerio-gist-web@3.23.3:
resolution: {integrity: sha512-m0kxuTMajDTmHhmTsH+4nbgoxDMVYE0U/sZGJRp6jug5F4uS/TdiNuZ+ACv+eyizA/wh744xY1MQbfXzDmgL2g==}
cva@1.0.0-beta.4:
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
peerDependencies:
@@ -6313,10 +6275,6 @@ packages:
engines: {node: '>=14'}
hasBin: true
js-cookie@3.0.1:
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
engines: {node: '>=12'}
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
@@ -6957,9 +6915,6 @@ packages:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
new-date@1.0.3:
resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==}
nlcst-to-string@4.0.0:
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
@@ -7013,9 +6968,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
obj-case@0.2.1:
resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -7856,9 +7808,6 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -8272,12 +8221,6 @@ packages:
unescape-js@1.1.4:
resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==}
unfetch@3.1.2:
resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==}
unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -8467,10 +8410,6 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
@@ -9602,30 +9541,6 @@ snapshots:
dependencies:
postcss-selector-parser: 7.1.1
'@customerio/cdp-analytics-browser@0.5.4':
dependencies:
'@customerio/cdp-analytics-core': 0.5.4
'@lukeed/uuid': 2.0.1
'@segment/analytics.js-video-plugins': 0.2.1
'@segment/facade': 3.4.10
customerio-gist-web: 3.23.3
dset: 3.1.4
js-cookie: 3.0.1
node-fetch: 2.7.0
spark-md5: 3.0.2
tslib: 2.8.1
unfetch: 4.2.0
transitivePeerDependencies:
- encoding
'@customerio/cdp-analytics-core@0.5.4':
dependencies:
'@lukeed/uuid': 2.0.1
dset: 3.1.4
tslib: 2.8.1
'@customerio/jist@0.1.8': {}
'@cyberalien/svg-utils@1.1.1':
dependencies:
'@iconify/types': 2.0.0
@@ -10585,12 +10500,6 @@ snapshots:
- ws
- zod
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
dependencies:
'@lukeed/csprng': 1.1.0
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
dependencies:
'@types/mdx': 2.0.13
@@ -11220,23 +11129,6 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@segment/analytics.js-video-plugins@0.2.1':
dependencies:
unfetch: 3.1.2
'@segment/facade@3.4.10':
dependencies:
'@segment/isodate-traverse': 1.1.1
inherits: 2.0.4
new-date: 1.0.3
obj-case: 0.2.1
'@segment/isodate-traverse@1.1.1':
dependencies:
'@segment/isodate': 1.0.3
'@segment/isodate@1.0.3': {}
'@sentry-internal/browser-utils@10.32.1':
dependencies:
'@sentry/core': 10.32.1
@@ -13325,11 +13217,6 @@ snapshots:
csstype@3.2.3: {}
customerio-gist-web@3.23.3:
dependencies:
'@customerio/jist': 0.1.8
uuid: 14.0.0
cva@1.0.0-beta.4(typescript@5.9.3):
dependencies:
clsx: 2.1.1
@@ -14725,8 +14612,6 @@ snapshots:
js-cookie: 3.0.5
nopt: 7.2.1
js-cookie@3.0.1: {}
js-cookie@3.0.5: {}
js-stringify@1.0.2: {}
@@ -15538,10 +15423,6 @@ snapshots:
neotraverse@0.6.18: {}
new-date@1.0.3:
dependencies:
'@segment/isodate': 1.0.3
nlcst-to-string@4.0.0:
dependencies:
'@types/nlcst': 2.0.3
@@ -15589,8 +15470,6 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.0.4
obj-case@0.2.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -16720,8 +16599,6 @@ snapshots:
space-separated-tokens@2.0.2: {}
spark-md5@3.0.2: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
@@ -17145,10 +17022,6 @@ snapshots:
dependencies:
string.fromcodepoint: 0.2.1
unfetch@3.1.2: {}
unfetch@4.2.0: {}
unicorn-magic@0.3.0: {}
unified@11.0.5:
@@ -17341,8 +17214,6 @@ snapshots:
uuid@11.1.0: {}
uuid@14.0.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3

View File

@@ -13,7 +13,6 @@ catalog:
'@astrojs/sitemap': ^3.7.1
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -87,14 +87,6 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -118,9 +110,6 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -277,19 +266,6 @@ describe('TopMenuSection', () => {
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,7 +148,6 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -283,14 +282,6 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,8 +222,7 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
button_id: 'actionbar_run_handle_drag_start'
})
})

View File

@@ -131,8 +131,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
button_id: 'queue_mode_option_run_on_change_selected'
})
queueMode.value = 'change'
}
@@ -146,8 +145,7 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
}
@@ -239,8 +237,7 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
button_id: 'queue_run_multiple_batches_submitted'
})
}

View File

@@ -88,8 +88,7 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -104,8 +103,7 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
button_id: 'breadcrumb_subgraph_item_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -40,8 +40,7 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source,
element_group: 'workflow_actions'
button_id: source
})
}
}

View File

@@ -190,7 +190,7 @@
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
class="group/card hover:bg-base-background"
class="hover:bg-base-background"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
@@ -316,11 +316,11 @@
class="flex flex-col-reverse justify-center"
>
<Button
v-if="hoveredTemplate === template.name"
v-tooltip.bottom="$t('g.seeTutorial')"
:aria-label="$t('g.seeTutorial')"
v-bind="$attrs"
variant="inverted"
size="icon"
class="not-group-hover/card:opacity-0"
@click.stop="openTutorial(template)"
>
<i class="icon-[lucide--info] size-4" />

View File

@@ -101,8 +101,7 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
button_id: 'error_dialog_show_report_clicked'
})
reportOpen.value = true
}

View File

@@ -25,8 +25,7 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
button_id: 'error_dialog_find_existing_issues_clicked'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -218,8 +218,7 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
button_id: 'graph_menu_minimap_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -229,8 +228,7 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
button_id: 'graph_menu_hide_links_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -65,8 +65,7 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
button_id: 'selection_toolbox_node_info_opened'
})
})

View File

@@ -24,8 +24,7 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
button_id: 'selection_toolbox_node_info_opened'
})
}
</script>

View File

@@ -23,8 +23,11 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
:source-format="sourceFormat"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@@ -86,7 +89,7 @@
/>
<RecordingControls
v-if="!isPreview"
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -117,9 +120,18 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -130,11 +142,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(props.nodeId!) ?? null
node.value = resolveNode(nodeId) ?? null
})
}
@@ -155,6 +167,7 @@ const {
canExport,
materialModes,
hasSkeleton,
sourceFormat,
hasRecording,
recordingDuration,
animations,

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -0,0 +1,21 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,6 +52,7 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -81,6 +82,7 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -89,6 +91,7 @@
<ExportControls
v-if="showExportControls"
:source-format="sourceFormat"
@export-model="handleExportModel"
/>
@@ -129,14 +132,20 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
hasSkeleton = false,
sourceFormat = null
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -59,6 +59,7 @@ function buildViewerStub() {
canUseGizmo: ref(true),
canUseLighting: ref(true),
canExport: ref(true),
sourceFormat: ref<string | null>(null),
materialModes: ref(['original', 'normal', 'wireframe']),
animations: ref<Array<{ name: string; index: number }>>([]),
playing: ref(false),

View File

@@ -82,7 +82,10 @@
</div>
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
<ExportControls @export-model="viewer.exportModel" />
<ExportControls
:source-format="viewer.sourceFormat.value"
@export-model="viewer.exportModel"
/>
</div>
</div>
</div>

View File

@@ -13,9 +13,12 @@ const i18n = createI18n({
}
})
function renderComponent(onExportModel?: (format: string) => void) {
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
const utils = render(ExportControls, {
props: { onExportModel },
props: { onExportModel, sourceFormat },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -63,6 +66,23 @@ describe('ExportControls', () => {
).not.toBeInTheDocument()
})
it('offers only the source format for direct-export files (e.g. ply)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'ply')
await user.click(screen.getByRole('button', { name: 'Export model' }))
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
expect(
screen.queryByRole('button', { name: label })
).not.toBeInTheDocument()
}
await user.click(screen.getByRole('button', { name: 'PLY' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
it('hides the popup when a click happens outside the trigger', async () => {
const { user } = renderComponent()

View File

@@ -35,9 +35,14 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
@@ -45,12 +50,7 @@ const emit = defineEmits<{
const showExportFormats = ref(false)
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
function toggleExportFormats() {
showExportFormats.value = !showExportFormats.value

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="showBackgroundImage && !hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,12 +83,16 @@
</div>
<PopupSlider
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -114,8 +118,9 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false } = defineProps<{
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -72,9 +72,12 @@ const i18n = createI18n({
messages: { en: { load3d: { export: 'Export' } } }
})
function renderComponent(onExportModel?: (format: string) => void) {
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
const utils = render(ViewerExportControls, {
props: { onExportModel },
props: { onExportModel, sourceFormat },
global: { plugins: [i18n] }
})
return { ...utils, user: userEvent.setup() }
@@ -114,4 +117,32 @@ describe('ViewerExportControls', () => {
expect(onExportModel).toHaveBeenCalledWith('glb')
})
it('offers only the source format for direct-export files (e.g. spz)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'spz')
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('spz')
})
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
const onExportModel = vi.fn()
const { user, rerender } = renderComponent(onExportModel, null)
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('obj')
await rerender({ onExportModel, sourceFormat: 'ply' })
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
})

View File

@@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
@@ -34,20 +34,30 @@ import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const exportFormat = ref('obj')
watch(
exportFormats,
(formats) => {
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
exportFormat.value = formats[0]?.value ?? ''
}
},
{ immediate: true }
)
const exportModel = (format: string) => {
emit('exportModel', format)
}

View File

@@ -14,7 +14,6 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -107,10 +106,6 @@ const isSingleSubgraphNode = computed(() => {
})
function closePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_closed',
element_group: 'right_side_panel'
})
rightSidePanelStore.closePanel()
}

View File

@@ -58,8 +58,7 @@ describe('useErrorActions', () => {
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_github_issues_clicked'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
@@ -124,8 +123,7 @@ describe('useErrorActions', () => {
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_find_existing_issues_clicked'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(

View File

@@ -9,8 +9,7 @@ export function useErrorActions() {
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
@@ -26,8 +25,7 @@ export function useErrorActions() {
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(

View File

@@ -92,7 +92,6 @@ import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
@@ -127,8 +126,6 @@ const placeholder = computed(() => {
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
useSearchQueryTracking('node_modal', currentQuery, suggestions)
// Debounced search tracking (500ms as per implementation plan)
const debouncedTrackSearch = debounce((query: string) => {
if (query.trim()) {

View File

@@ -66,7 +66,6 @@ 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'
@@ -131,12 +130,10 @@ const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
if (!node) return

View File

@@ -121,7 +121,6 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -340,8 +339,6 @@ const hoveredNodeDef = computed(
() => displayedResults.value[selectedIndex.value] ?? null
)
useSearchQueryTracking('node_modal', searchQuery, displayedResults)
watch(
hoveredNodeDef,
(newVal) => {

View File

@@ -150,8 +150,7 @@ const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened',
element_group: 'sidebar'
button_id: 'sidebar_comfy_menu_opened'
})
menuRef.value?.toggle(event)
}
@@ -218,8 +217,7 @@ const extraMenuItems = computed(() => [
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened',
element_group: 'sidebar'
button_id: 'sidebar_settings_menu_opened'
})
showSettings()
}
@@ -331,8 +329,7 @@ const handleNodes2ToggleClick = () => {
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
element_group: 'sidebar'
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
})
}
</script>

View File

@@ -138,23 +138,19 @@ const onTabClick = async (item: SidebarTabExtension) => {
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_node_library_selected'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_model_library_selected'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_workflows_selected'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
button_id: 'sidebar_tab_assets_media_selected'
})
await commandStore.commands

View File

@@ -21,8 +21,7 @@ const bottomPanelStore = useBottomPanelStore()
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled',
element_group: 'sidebar'
button_id: 'sidebar_bottom_panel_console_toggled'
})
bottomPanelStore.toggleBottomPanel()
}

View File

@@ -30,8 +30,7 @@ const tooltipText = computed(
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
button_id: 'sidebar_settings_button_clicked'
})
}
</script>

View File

@@ -37,8 +37,7 @@ const tooltipText = computed(
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled',
element_group: 'sidebar'
button_id: 'sidebar_shortcuts_panel_toggled'
})
bottomPanelStore.togglePanel('shortcuts')
}

View File

@@ -29,8 +29,7 @@ const isSmall = computed(
*/
const openTemplates = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_templates_dialog_opened',
element_group: 'sidebar'
button_id: 'sidebar_templates_dialog_opened'
})
useWorkflowTemplateSelectorDialog().show('sidebar')
}

View File

@@ -156,7 +156,6 @@ import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -202,7 +201,6 @@ const filteredWorkflows = computed(() => {
workflow.path.toLocaleLowerCase().includes(lowerQuery)
)
})
useSearchQueryTracking('apps', searchQuery, filteredWorkflows)
const filteredRoot = computed<TreeNode>(() => {
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
})

View File

@@ -65,7 +65,6 @@ 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'
@@ -156,8 +155,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
const graphNode = useLitegraphService().addNodeOnGraph(
provider.nodeDef
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key

View File

@@ -189,8 +189,6 @@ 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 { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useLitegraphService } from '@/services/litegraphService'
import {
DEFAULT_GROUPING_ID,
@@ -323,11 +321,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
handleClick(e: MouseEvent) {
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, this)
}
@@ -338,7 +333,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
})
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
const filters: Ref<
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
> = ref([])

View File

@@ -190,7 +190,6 @@ import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import {
DEFAULT_SORTING_ID,
DEFAULT_TAB_ID,
@@ -290,8 +289,6 @@ const activeNodes = computed(() =>
: filteredNodeDefs.value
)
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
const hasNoMatches = computed(
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
)

View File

@@ -39,7 +39,6 @@ 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'
@@ -184,11 +183,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
await nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, node)
}

View File

@@ -118,8 +118,7 @@ const toggleBookmark = async () => {
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button',
element_group: 'node_library'
button_id: 'node_library_help_button'
})
props.openNodeHelp(nodeDef.value)
}

View File

@@ -1,4 +1,3 @@
import { FirebaseError } from 'firebase/app'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -27,20 +26,9 @@ 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,
st: (key: string, fallback: string) => {
const code = key.replace('auth.errors.', '')
return knownAuthErrorCodes.has(code) ? key : fallback
}
values?.workflow ? `${key}:${values.workflow}` : key
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -84,7 +72,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: mockToastErrorHandler
toastErrorHandler: vi.fn()
})
}))
@@ -205,46 +193,3 @@ 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 { st, t } from '@/i18n'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -47,12 +47,6 @@ 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

@@ -1,6 +1,5 @@
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'
@@ -38,8 +37,7 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
const nodeDef = draggedNode.value
if (!nodeDef) return false
if (!draggedNode.value) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
@@ -48,9 +46,7 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
if (node) canvas.selectItems([node])
return true
}

View File

@@ -8,7 +8,6 @@ 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'
@@ -147,11 +146,9 @@ export function useJobMenu(
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
if (!node) return

View File

@@ -9,26 +9,16 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
@@ -39,7 +29,9 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(() => isAppModeValue(mode.value))
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -4,7 +4,6 @@ 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'
@@ -38,9 +37,7 @@ 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
withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(nodeDef, { pos })
)
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = basePos
@@ -61,8 +58,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
)
targetProvider = provider
}

View File

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

View File

@@ -38,8 +38,7 @@ export function useHelpCenter() {
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
button_id: 'sidebar_help_center_toggled'
})
helpCenterStore.toggle()
}

View File

@@ -169,6 +169,7 @@ describe('useLoad3d', () => {
exportModel: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,

View File

@@ -169,6 +169,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isPreview = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const sourceFormat = ref<string | null>(null)
const canFitToViewer = ref(true)
const canCenterCameraOnModel = ref(false)
const canUseGizmo = ref(true)
@@ -905,6 +906,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
sourceFormat.value = load3d?.getSourceFormat() ?? null
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
@@ -1070,6 +1072,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview,
isSplatModel,
isPlyModel,
sourceFormat,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,

View File

@@ -130,6 +130,7 @@ describe('useLoad3dViewer', () => {
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
@@ -618,7 +619,7 @@ describe('useLoad3dViewer', () => {
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: false,
exportable: false,
exportable: true,
materialModes: [],
fitTargetSize: 20
})
@@ -630,7 +631,7 @@ describe('useLoad3dViewer', () => {
expect.stringContaining('dropped.splat')
)
expect(viewer.canUseLighting.value).toBe(false)
expect(viewer.canExport.value).toBe(false)
expect(viewer.canExport.value).toBe(true)
expect(viewer.isSplatModel.value).toBe(true)
expect([...viewer.materialModes.value]).toEqual([])
})

View File

@@ -83,6 +83,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const isStandaloneMode = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const sourceFormat = ref<string | null>(null)
const canFitToViewer = ref(true)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
@@ -96,6 +97,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const captureAdapterFlags = (source: Load3d) => {
isSplatModel.value = source.isSplatModel()
isPlyModel.value = source.isPlyModel()
sourceFormat.value = source.getSourceFormat()
const caps = source.getCurrentModelCapabilities()
canFitToViewer.value = caps.fitToViewer
canUseGizmo.value = caps.gizmoTransform
@@ -839,6 +841,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isStandaloneMode,
isSplatModel,
isPlyModel,
sourceFormat,
canFitToViewer,
canUseGizmo,
canUseLighting,

View File

@@ -53,15 +53,10 @@ vi.mock('@/stores/systemStatsStore', () => ({
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackTemplateFilterChanged: vi.fn(),
trackSearchQuery: vi.fn()
trackTemplateFilterChanged: vi.fn()
}))
}))
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
useSearchQueryTracking: vi.fn()
}))
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {

View File

@@ -6,7 +6,6 @@ import type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -308,7 +307,6 @@ export function useTemplateFiltering(
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => visibleTemplates.value.length)
useSearchQueryTracking('templates', searchQuery, filteredTemplates)
// Template filter tracking (debounced to avoid excessive events)
const debouncedTrackFilterChange = debounce(() => {

View File

@@ -22,6 +22,7 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -413,16 +414,10 @@ useExtensionService().registerExtension({
if (cached) return cached
}
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
currentLoad3d.stopRecording()
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const {
scene: imageData,
@@ -441,16 +436,11 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
camera_info,
recording: '',
model_3d_info
}

View File

@@ -12,13 +12,17 @@ const {
exportGLBMock,
exportOBJMock,
exportSTLMock,
exportFBXMock
exportFBXMock,
exportDirectMock,
detectFormatFromURLMock
} = vi.hoisted(() => ({
cloneSkinnedMock: vi.fn(),
exportGLBMock: vi.fn(),
exportOBJMock: vi.fn(),
exportSTLMock: vi.fn(),
exportFBXMock: vi.fn()
exportFBXMock: vi.fn(),
exportDirectMock: vi.fn(),
detectFormatFromURLMock: vi.fn()
}))
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
@@ -30,7 +34,9 @@ vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
exportGLB: exportGLBMock,
exportOBJ: exportOBJMock,
exportSTL: exportSTLMock,
exportFBX: exportFBXMock
exportFBX: exportFBXMock,
exportDirect: exportDirectMock,
detectFormatFromURL: detectFormatFromURLMock
}
}))
@@ -1172,5 +1178,56 @@ describe('Load3d', () => {
'Unsupported export format: xyz'
)
})
it('downloads the source file directly for direct-export formats', async () => {
exportDirectMock.mockReset()
detectFormatFromURLMock.mockReturnValue('ply')
const model = new THREE.Object3D()
setupForExport({
currentModel: model,
originalFileName: 'cloud',
originalURL: 'http://example.com/api/view?filename=cloud.ply'
})
await ctx.load3d.exportModel('ply')
expect(exportDirectMock).toHaveBeenCalledWith(
'http://example.com/api/view?filename=cloud.ply',
'cloud.ply',
'ply'
)
expect(exportGLBMock).not.toHaveBeenCalled()
expect(exportOBJMock).not.toHaveBeenCalled()
expect(cloneSkinnedMock).not.toHaveBeenCalled()
})
it('refuses a direct export when the requested format differs from the source', async () => {
exportDirectMock.mockReset()
detectFormatFromURLMock.mockReturnValue('spz')
vi.spyOn(console, 'error').mockImplementation(() => {})
setupForExport({
currentModel: new THREE.Object3D(),
originalFileName: 'scene',
originalURL: 'http://example.com/api/view?filename=scene.spz'
})
await expect(ctx.load3d.exportModel('ply')).rejects.toThrow(
'Cannot export ply without converting from the loaded spz source'
)
expect(exportDirectMock).not.toHaveBeenCalled()
})
it('getSourceFormat derives the extension from the original URL', () => {
detectFormatFromURLMock.mockReturnValue('spz')
setupForExport({
currentModel: new THREE.Object3D(),
originalURL: 'http://example.com/api/view?filename=scene.spz'
})
expect(ctx.load3d.getSourceFormat()).toBe('spz')
expect(detectFormatFromURLMock).toHaveBeenCalledWith(
'http://example.com/api/view?filename=scene.spz'
)
})
})
})

View File

@@ -9,6 +9,7 @@ import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { DIRECT_EXPORT_FORMATS } from './constants'
import { ModelExporter } from './ModelExporter'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
@@ -359,6 +360,27 @@ class Load3d {
const exportMessage = `Exporting as ${format.toUpperCase()}...`
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
const originalFileName = this.modelManager.originalFileName || 'model'
const filename = `${originalFileName}.${format}`
const originalURL = this.modelManager.originalURL
if (DIRECT_EXPORT_FORMATS.has(format)) {
try {
if (this.getSourceFormat() !== format) {
throw new Error(
`Cannot export ${format} without converting from the loaded ${this.getSourceFormat() ?? 'unknown'} source`
)
}
await ModelExporter.exportDirect(originalURL, filename, format)
} catch (error) {
console.error(`Error exporting model as ${format}:`, error)
throw error
} finally {
this.eventManager.emitEvent('exportLoadingEnd', null)
}
return
}
const source = this.modelManager.currentModel
const savedPos = source.position.clone()
const savedRot = source.rotation.clone()
@@ -384,11 +406,6 @@ class Load3d {
? Object.assign(cloneSkinned(source), { animations: clips })
: source.clone()
const originalFileName = this.modelManager.originalFileName || 'model'
const filename = `${originalFileName}.${format}`
const originalURL = this.modelManager.originalURL
await new Promise((resolve) => setTimeout(resolve, 10))
switch (format) {
@@ -399,7 +416,7 @@ class Load3d {
await ModelExporter.exportOBJ(model, filename, originalURL)
break
case 'stl':
;(await ModelExporter.exportSTL(model, filename), originalURL)
await ModelExporter.exportSTL(model, filename, originalURL)
break
case 'fbx':
await ModelExporter.exportFBX(model, filename, originalURL)
@@ -421,6 +438,12 @@ class Load3d {
}
}
getSourceFormat(): string | null {
const url = this.modelManager.originalURL
if (!url) return null
return ModelExporter.detectFormatFromURL(url)
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)

View File

@@ -23,6 +23,7 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -58,6 +59,7 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -247,6 +249,24 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

View File

@@ -102,6 +102,8 @@ export class MeshModelAdapter implements ModelAdapter {
path: string,
filename: string
): Promise<THREE.Object3D> {
this.objLoader.setBaseObject3d(new THREE.Object3D())
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)

View File

@@ -338,6 +338,35 @@ describe('ModelExporter', () => {
})
})
describe('exportDirect', () => {
it('downloads the original source file unchanged', async () => {
const blob = new Blob(['x'])
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
)
await ModelExporter.exportDirect(
'http://example.com/api/view?filename=src.ply',
'out.ply',
'ply'
)
expect(downloadBlobMock).toHaveBeenCalledWith('out.ply', blob)
vi.unstubAllGlobals()
})
it('throws without toasting when there is no source URL, leaving the alert to the caller', async () => {
await expect(
ModelExporter.exportDirect(null, 'out.spz', 'spz')
).rejects.toThrow('No source file available to export as spz')
expect(downloadBlobMock).not.toHaveBeenCalled()
expect(addAlertMock).not.toHaveBeenCalled()
})
})
describe('exportFBX', () => {
it('uses the direct-URL fast path for matching .fbx URLs', async () => {
const blob = new Blob(['x'])

View File

@@ -184,6 +184,18 @@ export class ModelExporter {
}
}
static async exportDirect(
originalURL: string | null | undefined,
filename: string,
format: string
): Promise<void> {
if (!originalURL) {
throw new Error(`No source file available to export as ${format}`)
}
return ModelExporter.downloadFromURL(originalURL, filename)
}
private static saveArrayBuffer(buffer: ArrayBuffer, filename: string): void {
const blob = new Blob([buffer], { type: 'application/octet-stream' })
downloadBlob(filename, blob)

View File

@@ -63,7 +63,7 @@ describe('SplatModelAdapter', () => {
const adapter = new SplatModelAdapter()
expect(adapter.kind).toBe('splat')
expect(adapter.capabilities.lighting).toBe(false)
expect(adapter.capabilities.exportable).toBe(false)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([])
})

View File

@@ -19,7 +19,7 @@ export class SplatModelAdapter implements ModelAdapter {
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: false,
exportable: false,
exportable: true,
materialModes: [],
fitTargetSize: 20
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { getExportFormatOptions } from './constants'
describe('getExportFormatOptions', () => {
it('returns the convertible mesh formats for mesh sources', () => {
expect(getExportFormatOptions('glb').map((o) => o.value)).toEqual([
'glb',
'obj',
'stl',
'fbx'
])
})
it('returns the convertible mesh formats when the source is unknown', () => {
expect(getExportFormatOptions(null).map((o) => o.value)).toEqual([
'glb',
'obj',
'stl',
'fbx'
])
})
it.each(['ply', 'spz', 'splat', 'ksplat'])(
'offers only the source format for direct-export type %s',
(format) => {
expect(getExportFormatOptions(format)).toEqual([
{ label: format.toUpperCase(), value: format }
])
}
)
it('matches direct-export formats case-insensitively', () => {
expect(getExportFormatOptions('PLY')).toEqual([
{ label: 'PLY', value: 'ply' }
])
})
})

View File

@@ -24,3 +24,27 @@ export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
].join(',')
export const LOAD3D_NONE_MODEL = 'none'
export const DIRECT_EXPORT_FORMATS = new Set(['ply', 'spz', 'splat', 'ksplat'])
interface ExportFormatOption {
label: string
value: string
}
const CONVERTIBLE_EXPORT_FORMAT_OPTIONS: ExportFormatOption[] = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
export function getExportFormatOptions(
sourceFormat: string | null | undefined
): ExportFormatOption[] {
const format = sourceFormat?.toLowerCase()
if (format && DIRECT_EXPORT_FORMATS.has(format)) {
return [{ label: format.toUpperCase(), value: format }]
}
return CONVERTIBLE_EXPORT_FORMAT_OPTIONS
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
return { properties: { ...props } } as unknown as LGraphNode
}
const baseCameraState: CameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
} as unknown as CameraState
function makeLoad3d({
cameraType = 'perspective',
fov = 35,
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
}: {
cameraType?: string
fov?: number
modelInfo?: unknown
} = {}) {
return {
getCurrentCameraType: vi.fn(() => cameraType),
cameraManager: { perspectiveCamera: { fov } },
getCameraState: vi.fn(() => baseCameraState),
stopRecording: vi.fn(),
getModelInfo: vi.fn(() => modelInfo)
} as unknown as Load3d
}
describe('snapshotLoad3dState', () => {
it('returns only camera_info and model_3d_info', () => {
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
})
it('writes the camera state into properties["Camera Config"]', () => {
const node = makeNode()
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
const cfg = node.properties['Camera Config'] as Record<string, unknown>
expect(cfg).toMatchObject({
cameraType: 'perspective',
fov: 42,
state: baseCameraState
})
})
it('preserves an existing Camera Config object instead of replacing it', () => {
const existing = { cameraType: 'orthographic', fov: 99 }
const node = makeNode({ 'Camera Config': existing })
snapshotLoad3dState(node, makeLoad3d())
// Same object reference (mutated in place), with state attached.
expect(node.properties['Camera Config']).toBe(existing)
expect(
(node.properties['Camera Config'] as Record<string, unknown>).state
).toBe(baseCameraState)
})
it('stops in-progress recording as a side effect', () => {
const load3d = makeLoad3d()
snapshotLoad3dState(makeNode(), load3d)
expect(load3d.stopRecording).toHaveBeenCalledOnce()
})
it('returns model_3d_info as a single-element list when a model is loaded', () => {
const info = { transform: { position: [1, 2, 3] } }
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: info })
)
expect(result.model_3d_info).toEqual([info])
})
it('returns an empty model_3d_info list when no model is loaded', () => {
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: null })
)
expect(result.model_3d_info).toEqual([])
})
})

View File

@@ -0,0 +1,36 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
type Load3dSerializedBase = {
camera_info: CameraState | null
model_3d_info: Model3DInfo
}
export function snapshotLoad3dState(
node: LGraphNode,
load3d: Load3d
): Load3dSerializedBase {
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: load3d.getCurrentCameraType(),
fov: load3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = load3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
load3d.stopRecording()
const modelInfo = load3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
camera_info: cameraConfig.state ?? null,
model_3d_info
}
}

View File

@@ -9,7 +9,12 @@ const LOAD3D_PREVIEW_NODES = new Set([
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)

View File

@@ -0,0 +1,103 @@
import { nextTick } from 'vue'
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpecLoad3DAdvanced: CustomInputSpec = {
name: 'viewport_state',
type: 'LOAD_3D_ADVANCED',
isPreview: false
}
useExtensionService().registerExtension({
name: 'Comfy.Load3DAdvanced',
beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'Load3DAdvanced') return
if (!nodeData.input?.required) return
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
LOAD_3D_ADVANCED(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
})
widget.type = 'load3DAdvanced'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).onLoad3dReady((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (!modelWidget || !width || !height) return
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
config.configure({
loadFolder: 'input',
modelWidget,
cameraState,
width,
height
})
})
useLoad3d(node).waitForLoad3d(() => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
return snapshotLoad3dState(node, currentLoad3d)
}
})
}
})

View File

@@ -119,6 +119,23 @@ describe('load3dLazy', () => {
expect(spec.upload_subfolder).toBe('3d')
})
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3DAdvanced', {
input: {
required: { model_file: ['STRING', {}] }
}
} as Partial<ComfyNodeDef>)
await hook({} as typeof LGraphNode, nodeData)
const spec = (
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
)[1]
expect(spec.mesh_upload).toBe(true)
expect(spec.upload_subfolder).toBe('3d')
})
it('does not throw when a Load3D node has no model_file widget spec', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3D', {

View File

@@ -37,6 +37,7 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
@@ -60,12 +61,18 @@ useExtensionService().registerExtension({
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') {
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

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