Compare commits
53 Commits
version-bu
...
cloud/1.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e32d4484 | ||
|
|
0ef64699b2 | ||
|
|
e2f01a84ba | ||
|
|
63bddcd5d2 | ||
|
|
20a2e5df6f | ||
|
|
44d6a2754f | ||
|
|
4db0d8e41b | ||
|
|
9f4d058565 | ||
|
|
944b5d4d7f | ||
|
|
0bdd25f597 | ||
|
|
c5180870be | ||
|
|
459a6e3780 | ||
|
|
0dab4c9b2a | ||
|
|
6cc93673ff | ||
|
|
cdbb8cd3bc | ||
|
|
3d3198ab21 | ||
|
|
e2d70315b5 | ||
|
|
5d42aae7b2 | ||
|
|
337db984a5 | ||
|
|
1968bbaa2f | ||
|
|
991117a6b5 | ||
|
|
d5f63cf852 | ||
|
|
4d90e70298 | ||
|
|
ef30aaf93f | ||
|
|
45dbd37a7b | ||
|
|
3870b54360 | ||
|
|
383760e728 | ||
|
|
af8f0b60f5 | ||
|
|
434a1b1af1 | ||
|
|
bd6e5e2286 | ||
|
|
6a78e0b635 | ||
|
|
b751750f0b | ||
|
|
2fa47fa260 | ||
|
|
505728cc56 | ||
|
|
880af41f34 | ||
|
|
085bef657b | ||
|
|
f849e9be77 | ||
|
|
bd48bf1bbe | ||
|
|
75fb11785a | ||
|
|
5ff0b33295 | ||
|
|
e89778fc89 | ||
|
|
6f3ef2ed70 | ||
|
|
cd216d4db8 | ||
|
|
bccfc41f5d | ||
|
|
2bf1eb6e19 | ||
|
|
d2ad47634a | ||
|
|
daeae316b1 | ||
|
|
20cf8074a9 | ||
|
|
c87cb03024 | ||
|
|
28c4080134 | ||
|
|
1a6e77e955 | ||
|
|
e06d7a7b34 | ||
|
|
c76b7280af |
24
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -109,3 +109,27 @@ 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'
|
||||
|
||||
55
apps/website/src/scripts/posthog.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -18,7 +20,9 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only'
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createInputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createOutputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
|
||||
@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
const defaultInputAsset: Asset & { hash?: string } = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
const importedInputAsset: Asset & { hash?: string } = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
135
browser_tests/tests/cloudSurveyGate.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
|
||||
@@ -23,11 +23,31 @@ 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 = {
|
||||
const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
asset_hash: outputHash,
|
||||
hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
@@ -36,10 +56,10 @@ const cloudOutputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
@@ -50,10 +70,10 @@ const cloudUploadedVideoAsset: Asset = {
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -66,12 +86,168 @@ interface CloudUploadAssetState {
|
||||
isUploadedAssetAvailable: boolean
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
|
||||
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 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
|
||||
}
|
||||
@@ -106,9 +282,13 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}
|
||||
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await use(page)
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
try {
|
||||
await use(page)
|
||||
} finally {
|
||||
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
|
||||
await unrouteObjectInfo()
|
||||
cloudUploadAssetStateByPage.delete(page)
|
||||
}
|
||||
},
|
||||
markUploadedCloudAssetAvailable: async ({ page }, use) => {
|
||||
await use(() => {
|
||||
@@ -139,7 +319,41 @@ async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
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' }
|
||||
) {
|
||||
let releaseUpload!: () => void
|
||||
let resolveUploadStarted!: () => void
|
||||
const uploadStarted = new Promise<void>((resolve) => {
|
||||
@@ -152,6 +366,14 @@ async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -295,12 +517,51 @@ 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(
|
||||
@@ -329,13 +590,18 @@ 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)
|
||||
const delayedUpload = await delayNextUpload(comfyPage, {
|
||||
name: plainVideoFileName,
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
|
||||
dropPosition: graphDropPosition
|
||||
|
||||
@@ -15,10 +15,6 @@ 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'])
|
||||
|
||||
@@ -117,7 +113,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
@@ -140,7 +136,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -157,7 +153,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
@@ -171,7 +167,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
@@ -183,7 +179,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()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
@@ -197,7 +193,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -215,7 +211,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
|
||||
@@ -450,4 +451,57 @@ 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
@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface RewardfulGlobal {
|
||||
referral?: string
|
||||
affiliate?: { id?: string; token?: string; name?: string }
|
||||
campaign?: { id?: string; name?: string }
|
||||
}
|
||||
|
||||
interface RewardfulQueueFunction {
|
||||
(method: 'ready', callback: () => void): void
|
||||
(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
@@ -37,6 +49,11 @@ 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
|
||||
@@ -63,6 +80,8 @@ interface Window {
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
Rewardful?: RewardfulGlobal
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<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"
|
||||
|
||||
@@ -5,9 +5,16 @@
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/images/comfy-logo-single.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"display": "standalone"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.19",
|
||||
"version": "1.45.15",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -67,6 +67,7 @@
|
||||
"@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:",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
|
||||
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
@@ -65,6 +66,9 @@
|
||||
--color-ocean-600: #2f687a;
|
||||
--color-ocean-900: #253236;
|
||||
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
|
||||
6
packages/design-system/src/icons/bytedance-mono.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
|
||||
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
|
||||
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
|
||||
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
packages/design-system/src/icons/comfy-logo.svg
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
6
packages/design-system/src/icons/gemini-mono.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -7,7 +7,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -102,7 +102,6 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,7 +111,6 @@ 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')
|
||||
})
|
||||
|
||||
@@ -590,16 +590,8 @@ const IMAGE_EXTENSIONS = [
|
||||
'svg'
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] 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 AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
|
||||
58
packages/shared-frontend-utils/src/piiUtil.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createPostHogBeforeSend } from './piiUtil'
|
||||
|
||||
describe('createPostHogBeforeSend', () => {
|
||||
const beforeSend = createPostHogBeforeSend()
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(beforeSend(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('strips all PII keys from properties, $set, and $set_once', () => {
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'd@example.com',
|
||||
user_email: 'e@example.com',
|
||||
$email: 'f@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'g@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = beforeSend(event)!
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('handles missing property bags gracefully', () => {
|
||||
const event = { properties: { email: 'a@example.com', safe: true } }
|
||||
const result = beforeSend(event)!
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).toHaveProperty('safe', true)
|
||||
expect(result.$set).toBeUndefined()
|
||||
expect(result.$set_once).toBeUndefined()
|
||||
})
|
||||
})
|
||||
35
packages/shared-frontend-utils/src/piiUtil.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
|
||||
|
||||
function stripPiiKeys(obj?: Record<string, unknown>): void {
|
||||
if (!obj) return
|
||||
for (const key of PII_KEYS) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog before_send hook that strips PII from all three property bags
|
||||
* an event can carry: properties, $set, and $set_once.
|
||||
*
|
||||
* posthog.identify(id, { email }) lands in $set, not properties, so all
|
||||
* three bags must be sanitized.
|
||||
*
|
||||
* Ref: posthog.com/tutorials/web-redact-properties
|
||||
*/
|
||||
interface PostHogEventLike {
|
||||
properties?: Record<string, unknown>
|
||||
$set?: Record<string, unknown>
|
||||
$set_once?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPostHogBeforeSend() {
|
||||
return function beforeSend<E extends PostHogEventLike>(
|
||||
event: E | null
|
||||
): E | null {
|
||||
if (!event) return null
|
||||
stripPiiKeys(event.properties)
|
||||
stripPiiKeys(event.$set)
|
||||
stripPiiKeys(event.$set_once)
|
||||
return event
|
||||
}
|
||||
}
|
||||
129
pnpm-lock.yaml
generated
@@ -21,6 +21,9 @@ 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
|
||||
@@ -436,6 +439,9 @@ 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
|
||||
@@ -1419,6 +1425,15 @@ 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==}
|
||||
|
||||
@@ -2355,6 +2370,14 @@ 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:
|
||||
@@ -3254,6 +3277,18 @@ 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'}
|
||||
@@ -5042,6 +5077,9 @@ 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:
|
||||
@@ -6275,6 +6313,10 @@ 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'}
|
||||
@@ -6915,6 +6957,9 @@ 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==}
|
||||
|
||||
@@ -6968,6 +7013,9 @@ 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'}
|
||||
@@ -7808,6 +7856,9 @@ 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'}
|
||||
@@ -8221,6 +8272,12 @@ 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'}
|
||||
@@ -8410,6 +8467,10 @@ 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:
|
||||
@@ -9541,6 +9602,30 @@ 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
|
||||
@@ -10500,6 +10585,12 @@ 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
|
||||
@@ -11129,6 +11220,23 @@ 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
|
||||
@@ -13217,6 +13325,11 @@ 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
|
||||
@@ -14612,6 +14725,8 @@ 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: {}
|
||||
@@ -15423,6 +15538,10 @@ 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
|
||||
@@ -15470,6 +15589,8 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
tinyexec: 1.0.4
|
||||
|
||||
obj-case@0.2.1: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -16599,6 +16720,8 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
spark-md5@3.0.2: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
@@ -17022,6 +17145,10 @@ snapshots:
|
||||
dependencies:
|
||||
string.fromcodepoint: 0.2.1
|
||||
|
||||
unfetch@3.1.2: {}
|
||||
|
||||
unfetch@4.2.0: {}
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
|
||||
unified@11.0.5:
|
||||
@@ -17214,6 +17341,8 @@ snapshots:
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@14.0.0: {}
|
||||
|
||||
valibot@1.2.0(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/assets/images/comfy-icon-192.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
public/assets/images/comfy-icon-512.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
@@ -87,6 +87,14 @@ 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>
|
||||
@@ -110,6 +118,9 @@ function createWrapper({
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
},
|
||||
rightSidePanel: {
|
||||
togglePanel: 'Toggle properties panel'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +277,19 @@ 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)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
@@ -148,6 +148,7 @@ 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'
|
||||
@@ -282,6 +283,14 @@ 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)
|
||||
|
||||
@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
|
||||
*/
|
||||
useEventListener(dragHandleRef, 'mousedown', () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'actionbar_run_handle_drag_start'
|
||||
button_id: 'actionbar_run_handle_drag_start',
|
||||
element_group: 'actionbar'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.onChangeTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_on_change_selected'
|
||||
button_id: 'queue_mode_option_run_on_change_selected',
|
||||
element_group: 'queue'
|
||||
})
|
||||
queueMode.value = 'change'
|
||||
}
|
||||
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
|
||||
tooltip: t('menu.instantTooltip'),
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_mode_option_run_instant_selected'
|
||||
button_id: 'queue_mode_option_run_instant_selected',
|
||||
element_group: 'queue'
|
||||
})
|
||||
queueMode.value = 'instant-idle'
|
||||
}
|
||||
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
|
||||
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
button_id: 'queue_run_multiple_batches_submitted',
|
||||
element_group: 'queue'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,8 @@ const home = computed(() => ({
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
button_id: 'breadcrumb_subgraph_root_selected',
|
||||
element_group: 'breadcrumb'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
@@ -103,7 +104,8 @@ const items = computed(() => {
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
button_id: 'breadcrumb_subgraph_item_selected',
|
||||
element_group: 'breadcrumb'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
button_id: source,
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="hover:bg-base-background"
|
||||
class="group/card 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')"
|
||||
v-bind="$attrs"
|
||||
:aria-label="$t('g.seeTutorial')"
|
||||
variant="inverted"
|
||||
size="icon"
|
||||
class="not-group-hover/card:opacity-0"
|
||||
@click.stop="openTutorial(template)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
|
||||
@@ -101,7 +101,8 @@ const reportOpen = ref(false)
|
||||
*/
|
||||
const showReport = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_show_report_clicked'
|
||||
button_id: 'error_dialog_show_report_clicked',
|
||||
element_group: 'error_dialog'
|
||||
})
|
||||
reportOpen.value = true
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_dialog_find_existing_issues_clicked'
|
||||
button_id: 'error_dialog_find_existing_issues_clicked',
|
||||
element_group: 'error_dialog'
|
||||
})
|
||||
const query = encodeURIComponent(queryString.value)
|
||||
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
|
||||
|
||||
@@ -218,7 +218,8 @@ onMounted(() => {
|
||||
*/
|
||||
const onMinimapToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_minimap_toggle_clicked'
|
||||
button_id: 'graph_menu_minimap_toggle_clicked',
|
||||
element_group: 'graph_menu'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
|
||||
}
|
||||
@@ -228,7 +229,8 @@ const onMinimapToggleClick = () => {
|
||||
*/
|
||||
const onLinkVisibilityToggleClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_menu_hide_links_toggle_clicked'
|
||||
button_id: 'graph_menu_hide_links_toggle_clicked',
|
||||
element_group: 'graph_menu'
|
||||
})
|
||||
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ describe('InfoButton', () => {
|
||||
|
||||
expect(openNodeInfoMock).toHaveBeenCalled()
|
||||
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
button_id: 'selection_toolbox_node_info_opened',
|
||||
element_group: 'selection_toolbox'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ const onInfoClick = () => {
|
||||
if (!openNodeInfo()) return
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'selection_toolbox_node_info_opened'
|
||||
button_id: 'selection_toolbox_node_info_opened',
|
||||
element_group: 'selection_toolbox'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,11 +23,8 @@
|
||||
: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"
|
||||
@@ -89,7 +86,7 @@
|
||||
/>
|
||||
|
||||
<RecordingControls
|
||||
v-if="canUseRecording && !isPreview"
|
||||
v-if="!isPreview"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@@ -120,18 +117,9 @@ import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const {
|
||||
widget,
|
||||
nodeId,
|
||||
canUseRecording = true,
|
||||
canUseHdri = true,
|
||||
canUseBackgroundImage = true
|
||||
} = defineProps<{
|
||||
const props = defineProps<{
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
nodeId?: NodeId
|
||||
canUseRecording?: boolean
|
||||
canUseHdri?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
}>()
|
||||
|
||||
function isComponentWidget(
|
||||
@@ -142,11 +130,11 @@ function isComponentWidget(
|
||||
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
|
||||
if (isComponentWidget(widget)) {
|
||||
node.value = widget.node
|
||||
} else if (nodeId) {
|
||||
if (isComponentWidget(props.widget)) {
|
||||
node.value = props.widget.node
|
||||
} else if (props.nodeId) {
|
||||
onMounted(() => {
|
||||
node.value = resolveNode(nodeId) ?? null
|
||||
node.value = resolveNode(props.nodeId!) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,7 +155,6 @@ const {
|
||||
canExport,
|
||||
materialModes,
|
||||
hasSkeleton,
|
||||
sourceFormat,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -52,7 +52,6 @@
|
||||
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
|
||||
"
|
||||
@@ -82,7 +81,6 @@
|
||||
/>
|
||||
|
||||
<HDRIControls
|
||||
v-if="canUseHdri"
|
||||
v-model:hdri-config="lightConfig!.hdri"
|
||||
:has-background-image="!!sceneConfig?.backgroundImage"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@@ -91,7 +89,6 @@
|
||||
|
||||
<ExportControls
|
||||
v-if="showExportControls"
|
||||
:source-format="sourceFormat"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
@@ -132,20 +129,14 @@ const {
|
||||
canUseGizmo = true,
|
||||
canUseLighting = true,
|
||||
canExport = true,
|
||||
canUseHdri = true,
|
||||
canUseBackgroundImage = true,
|
||||
materialModes = ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
canUseGizmo?: boolean
|
||||
canUseLighting?: boolean
|
||||
canExport?: boolean
|
||||
canUseHdri?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -59,7 +59,6 @@ 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),
|
||||
|
||||
@@ -82,10 +82,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
|
||||
<ExportControls
|
||||
:source-format="viewer.sourceFormat.value"
|
||||
@export-model="viewer.exportModel"
|
||||
/>
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,9 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ExportControls, {
|
||||
props: { onExportModel, sourceFormat },
|
||||
props: { onExportModel },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
@@ -66,23 +63,6 @@ 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()
|
||||
|
||||
|
||||
@@ -35,14 +35,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { 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
|
||||
@@ -50,7 +45,12 @@ const emit = defineEmits<{
|
||||
|
||||
const showExportFormats = ref(false)
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
function toggleExportFormats() {
|
||||
showExportFormats.value = !showExportFormats.value
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="showBackgroundImage && !hasBackgroundImage">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.uploadBackgroundImage'),
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showBackgroundImage && hasBackgroundImage">
|
||||
<div v-if="hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.panoramaMode'),
|
||||
@@ -83,16 +83,12 @@
|
||||
</div>
|
||||
|
||||
<PopupSlider
|
||||
v-if="
|
||||
showBackgroundImage &&
|
||||
hasBackgroundImage &&
|
||||
backgroundRenderMode === 'panorama'
|
||||
"
|
||||
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
|
||||
<div v-if="showBackgroundImage && hasBackgroundImage">
|
||||
<div v-if="hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.removeBackgroundImage'),
|
||||
@@ -118,9 +114,8 @@ 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, showBackgroundImage = true } = defineProps<{
|
||||
const { hdriActive = false } = defineProps<{
|
||||
hdriActive?: boolean
|
||||
showBackgroundImage?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -72,12 +72,9 @@ const i18n = createI18n({
|
||||
messages: { en: { load3d: { export: 'Export' } } }
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
onExportModel?: (format: string) => void,
|
||||
sourceFormat: string | null = null
|
||||
) {
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ViewerExportControls, {
|
||||
props: { onExportModel, sourceFormat },
|
||||
props: { onExportModel },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
@@ -117,32 +114,4 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
@@ -34,30 +34,20 @@ 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 = computed(() => getExportFormatOptions(sourceFormat))
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
@@ -106,6 +107,10 @@ const isSingleSubgraphNode = computed(() => {
|
||||
})
|
||||
|
||||
function closePanel() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'right_side_panel_closed',
|
||||
element_group: 'right_side_panel'
|
||||
})
|
||||
rightSidePanelStore.closePanel()
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ describe('useErrorActions', () => {
|
||||
openGitHubIssues()
|
||||
|
||||
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
button_id: 'error_tab_github_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
})
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
mocks.staticUrls.githubIssues,
|
||||
@@ -123,7 +124,8 @@ describe('useErrorActions', () => {
|
||||
findOnGitHub('CUDA out of memory')
|
||||
|
||||
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
button_id: 'error_tab_find_existing_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
})
|
||||
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -9,7 +9,8 @@ export function useErrorActions() {
|
||||
|
||||
function openGitHubIssues() {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
button_id: 'error_tab_github_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -25,7 +26,8 @@ export function useErrorActions() {
|
||||
|
||||
function findOnGitHub(errorMessage: string) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_find_existing_issues_clicked'
|
||||
button_id: 'error_tab_find_existing_issues_clicked',
|
||||
element_group: 'errors_panel'
|
||||
})
|
||||
const query = encodeURIComponent(errorMessage + ' is:issue')
|
||||
window.open(
|
||||
|
||||
@@ -92,6 +92,7 @@ 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'
|
||||
@@ -126,6 +127,8 @@ 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()) {
|
||||
|
||||
@@ -66,6 +66,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -130,10 +131,12 @@ const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
const node = litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
)
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ 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'
|
||||
@@ -339,6 +340,8 @@ const hoveredNodeDef = computed(
|
||||
() => displayedResults.value[selectedIndex.value] ?? null
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_modal', searchQuery, displayedResults)
|
||||
|
||||
watch(
|
||||
hoveredNodeDef,
|
||||
(newVal) => {
|
||||
|
||||
@@ -150,7 +150,8 @@ const telemetry = useTelemetry()
|
||||
|
||||
function onLogoMenuClick(event: MouseEvent) {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_comfy_menu_opened'
|
||||
button_id: 'sidebar_comfy_menu_opened',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
menuRef.value?.toggle(event)
|
||||
}
|
||||
@@ -217,7 +218,8 @@ const extraMenuItems = computed(() => [
|
||||
icon: 'icon-[lucide--settings]',
|
||||
command: () => {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_settings_menu_opened'
|
||||
button_id: 'sidebar_settings_menu_opened',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
showSettings()
|
||||
}
|
||||
@@ -329,7 +331,8 @@ 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'}`
|
||||
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -138,19 +138,23 @@ const onTabClick = async (item: SidebarTabExtension) => {
|
||||
|
||||
if (isNodeLibraryTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_node_library_selected'
|
||||
button_id: 'sidebar_tab_node_library_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
else if (isModelLibraryTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_model_library_selected'
|
||||
button_id: 'sidebar_tab_model_library_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
else if (isWorkflowsTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_workflows_selected'
|
||||
button_id: 'sidebar_tab_workflows_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
else if (isAssetsTab)
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_tab_assets_media_selected'
|
||||
button_id: 'sidebar_tab_assets_media_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
|
||||
await commandStore.commands
|
||||
|
||||
@@ -21,7 +21,8 @@ const bottomPanelStore = useBottomPanelStore()
|
||||
*/
|
||||
const toggleConsole = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_bottom_panel_console_toggled'
|
||||
button_id: 'sidebar_bottom_panel_console_toggled',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ const tooltipText = computed(
|
||||
const showSettingsDialog = () => {
|
||||
command.function()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_settings_button_clicked'
|
||||
button_id: 'sidebar_settings_button_clicked',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,7 +37,8 @@ const tooltipText = computed(
|
||||
*/
|
||||
const toggleShortcutsPanel = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_shortcuts_panel_toggled'
|
||||
button_id: 'sidebar_shortcuts_panel_toggled',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
bottomPanelStore.togglePanel('shortcuts')
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ const isSmall = computed(
|
||||
*/
|
||||
const openTemplates = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_templates_dialog_opened'
|
||||
button_id: 'sidebar_templates_dialog_opened',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ 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,
|
||||
@@ -201,6 +202,7 @@ const filteredWorkflows = computed(() => {
|
||||
workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
)
|
||||
})
|
||||
useSearchQueryTracking('apps', searchQuery, filteredWorkflows)
|
||||
const filteredRoot = computed<TreeNode>(() => {
|
||||
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
@@ -155,8 +156,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = useLitegraphService().addNodeOnGraph(
|
||||
provider.nodeDef
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
|
||||
@@ -189,6 +189,8 @@ 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,
|
||||
@@ -321,8 +323,11 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
}
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
@@ -333,6 +338,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
})
|
||||
|
||||
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
const filters: Ref<
|
||||
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
|
||||
> = ref([])
|
||||
|
||||
@@ -190,6 +190,7 @@ 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,
|
||||
@@ -289,6 +290,8 @@ const activeNodes = computed(() =>
|
||||
: filteredNodeDefs.value
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
|
||||
const hasNoMatches = computed(
|
||||
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -183,8 +184,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
await nodeBookmarkStore.addBookmark(nodePath)
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ const toggleBookmark = async () => {
|
||||
|
||||
const onHelpClick = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'node_library_help_button'
|
||||
button_id: 'node_library_help_button',
|
||||
element_group: 'node_library'
|
||||
})
|
||||
props.openNodeHelp(nodeDef.value)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -26,9 +27,20 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
confirm: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
])
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, values?: { workflow?: string }) =>
|
||||
values?.workflow ? `${key}:${values.workflow}` : key
|
||||
values?.workflow ? `${key}:${values.workflow}` : key,
|
||||
st: (key: string, fallback: string) => {
|
||||
const code = key.replace('auth.errors.', '')
|
||||
return knownAuthErrorCodes.has(code) ? key : fallback
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -72,7 +84,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
||||
) => action,
|
||||
toastErrorHandler: vi.fn()
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -193,3 +205,46 @@ describe('useAuthActions.logout', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions.reportError', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the friendly message for a known Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.auth/invalid-credential'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.generic'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates non-Firebase errors to toastErrorHandler', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
const networkError = new TypeError('Failed to fetch')
|
||||
|
||||
reportError(networkError)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { st, t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -47,6 +47,12 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
|
||||
})
|
||||
} else {
|
||||
toastErrorHandler(error)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -37,7 +38,8 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
if (!draggedNode.value) return false
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
const canvas = useCanvasStore().canvas
|
||||
if (!canvas) return false
|
||||
if (!isOverCanvas(clientX, clientY)) return false
|
||||
@@ -46,7 +48,9 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
if (node) canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
@@ -146,9 +147,11 @@ export function useJobMenu(
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -9,16 +9,26 @@ 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(
|
||||
() =>
|
||||
workflowStore.activeWorkflow?.activeMode ??
|
||||
workflowStore.activeWorkflow?.initialMode ??
|
||||
'graph'
|
||||
)
|
||||
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
|
||||
|
||||
const isBuilderMode = computed(
|
||||
() => isSelectMode.value || isArrangeMode.value
|
||||
@@ -29,9 +39,7 @@ export function useAppMode() {
|
||||
() => isSelectInputsMode.value || isSelectOutputsMode.value
|
||||
)
|
||||
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isAppMode = computed(() => isAppModeValue(mode.value))
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || isSelectMode.value
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -37,7 +38,9 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
} else if (node.data instanceof ComfyModelDef) {
|
||||
const model = node.data
|
||||
const pos = basePos
|
||||
@@ -58,11 +61,8 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
if (!targetGraphNode) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
targetGraphNode = litegraphService.addNodeOnGraph(
|
||||
provider.nodeDef,
|
||||
{
|
||||
pos
|
||||
}
|
||||
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
|
||||
)
|
||||
targetProvider = provider
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -107,7 +108,8 @@ export function useFeatureFlags() {
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
|
||||
@@ -38,7 +38,8 @@ export function useHelpCenter() {
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
button_id: 'sidebar_help_center_toggled',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
@@ -169,7 +169,6 @@ 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,
|
||||
|
||||
@@ -169,7 +169,6 @@ 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)
|
||||
@@ -906,7 +905,6 @@ 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
|
||||
@@ -1072,7 +1070,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
|
||||
@@ -130,7 +130,6 @@ 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,
|
||||
@@ -619,7 +618,7 @@ describe('useLoad3dViewer', () => {
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: true,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
})
|
||||
@@ -631,7 +630,7 @@ describe('useLoad3dViewer', () => {
|
||||
expect.stringContaining('dropped.splat')
|
||||
)
|
||||
expect(viewer.canUseLighting.value).toBe(false)
|
||||
expect(viewer.canExport.value).toBe(true)
|
||||
expect(viewer.canExport.value).toBe(false)
|
||||
expect(viewer.isSplatModel.value).toBe(true)
|
||||
expect([...viewer.materialModes.value]).toEqual([])
|
||||
})
|
||||
|
||||
@@ -83,7 +83,6 @@ 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)
|
||||
@@ -97,7 +96,6 @@ 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
|
||||
@@ -841,7 +839,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isStandaloneMode,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
sourceFormat,
|
||||
canFitToViewer,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
|
||||
@@ -53,10 +53,15 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackTemplateFilterChanged: vi.fn()
|
||||
trackTemplateFilterChanged: vi.fn(),
|
||||
trackSearchQuery: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
@@ -307,6 +308,7 @@ 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(() => {
|
||||
|
||||
@@ -22,7 +22,6 @@ 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'
|
||||
@@ -414,10 +413,16 @@ useExtensionService().registerExtension({
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const { camera_info, model_3d_info } = snapshotLoad3dState(
|
||||
node,
|
||||
currentLoad3d
|
||||
)
|
||||
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 {
|
||||
scene: imageData,
|
||||
@@ -436,11 +441,16 @@ 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,
|
||||
camera_info:
|
||||
(node.properties['Camera Config'] as CameraConfig | undefined)
|
||||
?.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
|
||||
@@ -12,17 +12,13 @@ const {
|
||||
exportGLBMock,
|
||||
exportOBJMock,
|
||||
exportSTLMock,
|
||||
exportFBXMock,
|
||||
exportDirectMock,
|
||||
detectFormatFromURLMock
|
||||
exportFBXMock
|
||||
} = vi.hoisted(() => ({
|
||||
cloneSkinnedMock: vi.fn(),
|
||||
exportGLBMock: vi.fn(),
|
||||
exportOBJMock: vi.fn(),
|
||||
exportSTLMock: vi.fn(),
|
||||
exportFBXMock: vi.fn(),
|
||||
exportDirectMock: vi.fn(),
|
||||
detectFormatFromURLMock: vi.fn()
|
||||
exportFBXMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
|
||||
@@ -34,9 +30,7 @@ vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
|
||||
exportGLB: exportGLBMock,
|
||||
exportOBJ: exportOBJMock,
|
||||
exportSTL: exportSTLMock,
|
||||
exportFBX: exportFBXMock,
|
||||
exportDirect: exportDirectMock,
|
||||
detectFormatFromURL: detectFormatFromURLMock
|
||||
exportFBX: exportFBXMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1178,56 +1172,5 @@ 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ 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'
|
||||
@@ -360,27 +359,6 @@ 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()
|
||||
@@ -406,6 +384,11 @@ 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) {
|
||||
@@ -416,7 +399,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)
|
||||
@@ -438,12 +421,6 @@ 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)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ const mtlLoaderStub = {
|
||||
const objLoaderStub = {
|
||||
setWorkerUrl: vi.fn(),
|
||||
setMaterials: vi.fn(),
|
||||
setBaseObject3d: vi.fn(),
|
||||
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
|
||||
}
|
||||
|
||||
@@ -59,7 +58,6 @@ vi.mock('wwobjloader2', () => ({
|
||||
OBJLoader2Parallel: class {
|
||||
setWorkerUrl = objLoaderStub.setWorkerUrl
|
||||
setMaterials = objLoaderStub.setMaterials
|
||||
setBaseObject3d = objLoaderStub.setBaseObject3d
|
||||
loadAsync = objLoaderStub.loadAsync
|
||||
},
|
||||
MtlObjBridge: {
|
||||
@@ -249,24 +247,6 @@ 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', () => {
|
||||
|
||||
@@ -102,8 +102,6 @@ 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)
|
||||
|
||||
@@ -338,35 +338,6 @@ 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'])
|
||||
|
||||
@@ -184,18 +184,6 @@ 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)
|
||||
|
||||
@@ -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(true)
|
||||
expect(adapter.capabilities.exportable).toBe(false)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class SplatModelAdapter implements ModelAdapter {
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: false,
|
||||
exportable: true,
|
||||
exportable: false,
|
||||
materialModes: [],
|
||||
fitTargetSize: 20
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -24,27 +24,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,7 @@ const LOAD3D_PREVIEW_NODES = new Set([
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
|
||||
const LOAD3D_ALL_NODES = new Set([
|
||||
...LOAD3D_PREVIEW_NODES,
|
||||
'Load3D',
|
||||
'Load3DAdvanced',
|
||||
'SaveGLB'
|
||||
])
|
||||
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
|
||||
|
||||
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
|
||||
LOAD3D_PREVIEW_NODES.has(nodeType)
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -119,23 +119,6 @@ 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', {
|
||||
|
||||
@@ -37,7 +37,6 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
|
||||
// Import extensions - they self-register via useExtensionService()
|
||||
await Promise.all([
|
||||
import('./load3d'),
|
||||
import('./load3dAdvanced'),
|
||||
import('./load3dPreviewExtensions'),
|
||||
import('./saveMesh')
|
||||
])
|
||||
@@ -61,18 +60,12 @@ 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' || nodeData.name === 'Load3DAdvanced') {
|
||||
if (nodeData.name === 'Load3D') {
|
||||
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,
|
||||
|
||||