Compare commits
15 Commits
cloud/v1.4
...
v1.46.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
682bd14061 | ||
|
|
5b48bf67a9 | ||
|
|
bbaaa82125 | ||
|
|
601cec68b9 | ||
|
|
8d1a170136 | ||
|
|
08ee925811 | ||
|
|
fb5b4a62ba | ||
|
|
cb62604d21 | ||
|
|
d02c5d374f | ||
|
|
c0ef283a05 | ||
|
|
d405002127 | ||
|
|
abd233d10d | ||
|
|
e1049a99a3 | ||
|
|
3da6e1766e | ||
|
|
52830a9e73 |
@@ -32,12 +32,12 @@
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -139,13 +139,13 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit`
|
||||
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
pnpm test:unit "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
@@ -368,7 +368,7 @@ Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit -- <test-file>
|
||||
pnpm test:unit <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
|
||||
@@ -169,7 +169,7 @@ expect(result).toBeDefined() // This proves nothing
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit -- --update
|
||||
pnpm test:unit --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
4
apps/website/public/favicon-dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
11
apps/website/public/favicon-light.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -71,8 +71,18 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -20,9 +18,7 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,10 +27,6 @@ export class ContextMenu {
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
menuItem(name: string): Locator {
|
||||
return this.anyMenu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a litegraph menu entry. Selects the most recently opened matching
|
||||
* entry so nested submenu items can be reached without being shadowed by
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
@@ -17,13 +16,12 @@ function createModelAsset(
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -34,13 +32,12 @@ function createInputAsset(
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
|
||||
@@ -11,11 +11,6 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
|
||||
|
||||
type RunOptions = {
|
||||
nodeErrors?: Record<string, NodeError>
|
||||
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `NodeError` describing a single failed input on a KSampler node.
|
||||
* Shared between specs that surface validation rings via 400 responses.
|
||||
@@ -75,9 +70,8 @@ export class ExecutionHelper {
|
||||
* The app receives a valid PromptResponse so storeJob() fires
|
||||
* and registers the job against the active workflow path.
|
||||
*/
|
||||
async run(options: RunOptions = {}): Promise<string> {
|
||||
async run(): Promise<string> {
|
||||
const jobId = `test-job-${++this.jobCounter}`
|
||||
const { nodeErrors = {}, onPromptRequest } = options
|
||||
|
||||
let fulfilled!: () => void
|
||||
const prompted = new Promise<void>((r) => {
|
||||
@@ -87,13 +81,12 @@ export class ExecutionHelper {
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await onPromptRequest?.(route.request().postDataJSON())
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
prompt_id: jobId,
|
||||
node_errors: nodeErrors
|
||||
node_errors: {}
|
||||
})
|
||||
})
|
||||
fulfilled()
|
||||
|
||||
@@ -51,6 +51,20 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setServerFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setServerFlags({ [name]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -216,16 +216,6 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
const node = await this.getFirstNodeRef()
|
||||
if (!node) {
|
||||
throw new Error('No nodes found to convert')
|
||||
}
|
||||
await node.clickContextMenuOption('Convert to Group Node')
|
||||
await this.fillPromptDialog(groupNodeName)
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
await this.promptDialogInput.fill(value)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -135,8 +135,7 @@ export const TestIds = {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
colorRed: 'red'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
|
||||
@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset & { hash?: string } = {
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
hash: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset & { hash?: string } = {
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
hash: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -514,17 +514,6 @@ export class NodeReference {
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
|
||||
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(`Did not find single group node (found=${nodes.length})`)
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ChangeTrackerDebugState = {
|
||||
changeCount: number
|
||||
@@ -311,28 +310,4 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Tracks convert to subgraph as undo step',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
|
||||
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
|
||||
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
|
||||
|
||||
await input.fill('40')
|
||||
await node.title.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
|
||||
.click()
|
||||
await expect(input).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('40')
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('512')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,60 +1,7 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const VALIDATION_ERROR_NODE_ID = '1'
|
||||
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
|
||||
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
|
||||
|
||||
type PromptRequestNode = {
|
||||
class_type?: string
|
||||
}
|
||||
|
||||
type PromptRequestBody = {
|
||||
prompt?: Record<string, PromptRequestNode>
|
||||
}
|
||||
|
||||
function buildPreviewAnyValidationError(): NodeError {
|
||||
return {
|
||||
class_type: 'PreviewAny',
|
||||
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: VALIDATION_ERROR_MESSAGE,
|
||||
details: '',
|
||||
extra_info: { input_name: 'source' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function expectPartialExecutionRootNodes(requestBody: unknown): void {
|
||||
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
|
||||
|
||||
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
|
||||
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getValidationErrorMessage(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluate(
|
||||
(nodeId) =>
|
||||
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
|
||||
?.message ?? null,
|
||||
VALIDATION_ERROR_NODE_ID
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -127,48 +74,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
|
||||
test('preserves validation errors when another active root starts execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const nodeErrors = {
|
||||
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
|
||||
}
|
||||
let promptRequestBody: unknown
|
||||
|
||||
const jobId = await exec.run({
|
||||
nodeErrors,
|
||||
onPromptRequest: (requestBody) => {
|
||||
promptRequestBody = requestBody
|
||||
}
|
||||
})
|
||||
expectPartialExecutionRootNodes(promptRequestBody)
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_NAME = 'group_node'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
|
||||
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -18,22 +23,19 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
const groupNodeCategory = 'group nodes>workflow'
|
||||
const groupNodeBookmarkName = `workflow>${groupNodeName}`
|
||||
let libraryTab: NodeLibrarySidebarTab
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
libraryTab = comfyPage.menu.nodeLibraryTab
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({
|
||||
comfyPage: _comfyPage
|
||||
}) => {
|
||||
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
|
||||
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
@@ -41,9 +43,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
// Add group node from node library sidebar
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getNode(groupNodeName).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab.getNode(GROUP_NODE_NAME).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
await expect
|
||||
@@ -52,9 +53,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
@@ -63,13 +64,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.poll(() =>
|
||||
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
)
|
||||
.toEqual([groupNodeBookmarkName])
|
||||
.toEqual([GROUP_NODE_BOOKMARK])
|
||||
// Verify the bookmark node with the same name is added to the tree
|
||||
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
|
||||
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
|
||||
|
||||
// Unbookmark the node
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
@@ -96,72 +96,57 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
comfyPage.page.locator('.node-lib-node-preview')
|
||||
).toBeVisible()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.getNode(GROUP_NODE_NAME)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Can be added to canvas using search',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(groupNodeName)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
test('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
|
||||
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
|
||||
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
|
||||
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${groupNodeName}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
const exactGroupNodeResult = comfyPage.searchBox.dropdown
|
||||
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
|
||||
.first()
|
||||
await expect(exactGroupNodeResult).toBeVisible()
|
||||
await exactGroupNodeResult.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
|
||||
.toHaveLength(2)
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
const pos = await groupNode.getPosition()
|
||||
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const makeGroup = async (name: string, type1: string, type2: string) => {
|
||||
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
|
||||
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
|
||||
await node1.click('title')
|
||||
await node2.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
return await node2.convertToGroupNode(name)
|
||||
}
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
|
||||
const group1 = await makeGroup(
|
||||
'g1',
|
||||
'CLIPTextEncode',
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
|
||||
|
||||
const manage1 = await group1.manageGroupNode()
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
|
||||
await manage1.close()
|
||||
await expect(manage1.root).toBeHidden()
|
||||
|
||||
const manage2 = await group2.manageGroupNode()
|
||||
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
|
||||
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
|
||||
await manage.close()
|
||||
await expect(manage.root).toBeHidden()
|
||||
})
|
||||
|
||||
test('Preserves hidden input configuration when containing duplicate node types', async ({
|
||||
@@ -201,42 +186,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
|
||||
expect(nodes).toHaveLength(1)
|
||||
return nodes[0]
|
||||
}
|
||||
const latent = await expectSingleNode('EmptyLatentImage')
|
||||
const sampler = await expectSingleNode('KSampler')
|
||||
// Remove existing link
|
||||
const samplerInput = await sampler.getInput(0)
|
||||
await samplerInput.removeLinks()
|
||||
// Group latent + sampler
|
||||
await latent.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
await sampler.click('title', {
|
||||
modifiers: ['Shift']
|
||||
})
|
||||
const groupNode = await sampler.convertToGroupNode()
|
||||
// Connect node to group
|
||||
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
|
||||
const input = await ckpt.connectOutput(0, groupNode, 0)
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
// Modify the group node via manage dialog
|
||||
const manage = await groupNode.manageGroupNode()
|
||||
await manage.selectNode('KSampler')
|
||||
await manage.changeTab('Inputs')
|
||||
await manage.setLabel('model', 'test')
|
||||
await manage.save()
|
||||
await manage.close()
|
||||
// Ensure the link is still present
|
||||
await expect.poll(() => input.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -249,11 +198,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.describe('Copy and paste', () => {
|
||||
let groupNode: NodeReference | null
|
||||
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
|
||||
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
|
||||
const GROUP_NODE_PREFIX = 'workflow>'
|
||||
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
|
||||
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
|
||||
|
||||
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
|
||||
return await comfyPage.page.evaluate((nodeType: string) => {
|
||||
@@ -282,10 +226,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
|
||||
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
|
||||
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
|
||||
if (!groupNode)
|
||||
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
|
||||
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
@@ -299,10 +243,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test('Copies and pastes group node after clearing workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Set setting
|
||||
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
// Clear workflow
|
||||
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
|
||||
|
||||
await comfyPage.clipboard.paste()
|
||||
@@ -342,24 +283,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Convert to group node, no selection', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.textEncodeNode1
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.keyboard.press('Alt+g')
|
||||
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
|
||||
@@ -166,6 +166,15 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
// Move mouse away to avoid hover highlight on the node at the drop position.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Duplication', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
|
||||
|
After Width: | Height: | Size: 93 KiB |
@@ -9,7 +9,7 @@ test.describe(
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
@@ -46,15 +46,8 @@ test.describe(
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 600 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
|
||||
@@ -309,6 +309,50 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Empty graph defaults', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.featureFlags.setServerFlag(
|
||||
'node_library_essentials_enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Defaults to Essentials when graph is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
const essentialsBtn = searchBoxV2.rootCategoryButton(
|
||||
RootCategory.Essentials
|
||||
)
|
||||
await expect(essentialsBtn).toBeVisible()
|
||||
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Defaults to Most Relevant when graph has nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
|
||||
@@ -12,10 +12,11 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
|
||||
@@ -24,10 +24,10 @@ const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
|
||||
const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
const cloudOutputAsset: Asset = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
hash: outputHash,
|
||||
asset_hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
@@ -36,10 +36,10 @@ const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
hash: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
@@ -50,10 +50,10 @@ const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
hash: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -35,23 +35,6 @@ test.describe(
|
||||
'add-group-group-added.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.canvasOps.rightClick()
|
||||
await comfyPage.contextMenu.clickMenuItem(
|
||||
'Convert to Group Node (Deprecated)'
|
||||
)
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.expectScreenshot(
|
||||
comfyPage.canvas,
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
@@ -1,101 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
/**
|
||||
* Expanded folder view must drop output records that resolve to the same
|
||||
* composite `${nodeId}-${subfolder}-${filename}` key; otherwise Vue's keyed
|
||||
* v-for in VirtualGrid collides and one asset visibly duplicates its
|
||||
* neighbours while scrolling.
|
||||
*/
|
||||
|
||||
const STACK_JOB_ID = 'job-output-dedupe'
|
||||
const COVER_NODE_ID = '9'
|
||||
const COVER_FILENAME = 'cover_00001_.png'
|
||||
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
|
||||
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']
|
||||
|
||||
// 5 records: 1 cover + 2 distinct + 2 sharing DUPLICATE_FILENAME.
|
||||
// 4 unique composite keys expected after dedupe.
|
||||
const STACK_JOB_OUTPUTS = [
|
||||
{ filename: COVER_FILENAME, subfolder: '', type: 'output' as const },
|
||||
...DISTINCT_FILENAMES.map((filename) => ({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output' as const
|
||||
})),
|
||||
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const },
|
||||
{ filename: DUPLICATE_FILENAME, subfolder: '', type: 'output' as const }
|
||||
]
|
||||
|
||||
const STACK_JOB = createMockJob({
|
||||
id: STACK_JOB_ID,
|
||||
create_time: 5000,
|
||||
execution_start_time: 5000,
|
||||
execution_end_time: 5050,
|
||||
preview_output: {
|
||||
filename: COVER_FILENAME,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: COVER_NODE_ID,
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: STACK_JOB_OUTPUTS.length
|
||||
})
|
||||
|
||||
const STACK_JOB_DETAIL: JobDetail = {
|
||||
...STACK_JOB,
|
||||
outputs: {
|
||||
[COVER_NODE_ID]: { images: STACK_JOB_OUTPUTS }
|
||||
}
|
||||
}
|
||||
|
||||
const EXPECTED_TOTAL_TILES = 4
|
||||
|
||||
test.describe(
|
||||
'Expanded folder view dedupes duplicate composite output keys',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
// @cloud comfyPage already navigates with Firebase auth seeded; a second
|
||||
// setup() call would clear localStorage and bounce to /cloud/login.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([STACK_JOB])
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.assets.mockJobDetail(STACK_JOB_ID, STACK_JOB_DETAIL)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('renders one tile per unique composite key', async ({
|
||||
comfyPage
|
||||
}, testInfo) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
await expect(tab.backToAssetsButton).toBeVisible()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(EXPECTED_TOTAL_TILES)
|
||||
|
||||
const labels = await tab.assetCards.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((el) => el.getAttribute('aria-label'))
|
||||
.filter((v): v is string => v !== null)
|
||||
)
|
||||
expect(new Set(labels).size).toBe(labels.length)
|
||||
|
||||
await testInfo.attach('expanded-folder-view.png', {
|
||||
body: await comfyPage.page.screenshot({ fullPage: false }),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -650,12 +651,6 @@ test(
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
@@ -664,6 +659,9 @@ test(
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
@@ -701,18 +699,5 @@ test(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Can disconnect link by right click', async () => {
|
||||
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
|
||||
const { x, y } = await stepsIOSlot.getPosition()
|
||||
await comfyPage.page.mouse.click(x, y, { button: 'right' })
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
|
||||
await expect(slotParent).toHaveCSS('opacity', '0')
|
||||
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const postScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -166,7 +166,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
@@ -174,33 +174,12 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('shows exactly one bypass menu item per state (FE-720 regression)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
const bypassItem = comfyPage.contextMenu.menuItem('Bypass')
|
||||
const removeBypassItem = comfyPage.contextMenu.menuItem('Remove Bypass')
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await expect(bypassItem).toHaveCount(1)
|
||||
await expect(removeBypassItem).toHaveCount(0)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await expect(removeBypassItem).toHaveCount(1)
|
||||
await expect(bypassItem).toHaveCount(0)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
})
|
||||
|
||||
test('should minimize and expand node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -472,7 +451,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
|
||||
}
|
||||
|
||||
@@ -481,7 +460,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
@@ -528,25 +507,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
.toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('should convert to group node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'workflow>TestGroupNode'
|
||||
)
|
||||
return groupNodes.length
|
||||
})
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
test('should convert selected nodes to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -55,24 +55,4 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('Can load dynamic combos', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
|
||||
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
|
||||
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
|
||||
|
||||
await test.step('Update value of the dynamic combo widget', async () => {
|
||||
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
|
||||
await expect(widget).toHaveText('scale width')
|
||||
})
|
||||
|
||||
await test.step('Swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await expect(widget).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(widget).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(widget, 'Widget has restored value').toHaveText('scale width')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,9 @@ See `docs/testing/*.md` for detailed patterns.
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pnpm test:unit # Run all unit tests
|
||||
pnpm test:unit -- path/to/file # Run specific test
|
||||
pnpm test:unit -- --watch # Watch mode
|
||||
pnpm test:unit # Run all unit tests
|
||||
pnpm test:unit path/to/file # Filter by substring of test file path
|
||||
pnpm test:unit foo.test.ts -t "name" # Filter by test name (regex; it()/test() only, not describe())
|
||||
```
|
||||
|
||||
Do not use the `--` separator before vitest args; pnpm forwards extra args automatically, and `--` mangles quoted args (e.g. `-t "two words"`) on Windows PowerShell.
|
||||
|
||||
@@ -43,10 +43,10 @@ To run the tests locally:
|
||||
pnpm test:unit
|
||||
|
||||
# Run a specific test file
|
||||
pnpm test:unit -- src/path/to/file.test.ts
|
||||
pnpm test:unit src/path/to/file.test.ts
|
||||
|
||||
# Run unit tests in watch mode
|
||||
pnpm test:unit -- --watch
|
||||
pnpm test:unit --watch
|
||||
```
|
||||
|
||||
Refer to the specific guides for more detailed information on each testing type.
|
||||
|
||||
14
global.d.ts
vendored
@@ -11,18 +11,6 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface RewardfulGlobal {
|
||||
referral?: string
|
||||
affiliate?: { id?: string; token?: string; name?: string }
|
||||
campaign?: { id?: string; name?: string }
|
||||
}
|
||||
|
||||
interface RewardfulQueueFunction {
|
||||
(method: 'ready', callback: () => void): void
|
||||
(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
@@ -75,8 +63,6 @@ interface Window {
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
Rewardful?: RewardfulGlobal
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.15",
|
||||
"version": "1.46.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -206,8 +206,8 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x",
|
||||
"pnpm": ">=11"
|
||||
"node": ">=25",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.1"
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
|
||||
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
@@ -66,9 +65,6 @@
|
||||
--color-ocean-600: #2f687a;
|
||||
--color-ocean-900: #253236;
|
||||
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
|
||||
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
|
||||
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
|
||||
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -7,8 +7,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createPostHogBeforeSend } from './piiUtil'
|
||||
|
||||
describe('createPostHogBeforeSend', () => {
|
||||
const beforeSend = createPostHogBeforeSend()
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(beforeSend(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('strips all PII keys from properties, $set, and $set_once', () => {
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'd@example.com',
|
||||
user_email: 'e@example.com',
|
||||
$email: 'f@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'g@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = beforeSend(event)!
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('handles missing property bags gracefully', () => {
|
||||
const event = { properties: { email: 'a@example.com', safe: true } }
|
||||
const result = beforeSend(event)!
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).toHaveProperty('safe', true)
|
||||
expect(result.$set).toBeUndefined()
|
||||
expect(result.$set_once).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
|
||||
|
||||
function stripPiiKeys(obj?: Record<string, unknown>): void {
|
||||
if (!obj) return
|
||||
for (const key of PII_KEYS) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog before_send hook that strips PII from all three property bags
|
||||
* an event can carry: properties, $set, and $set_once.
|
||||
*
|
||||
* posthog.identify(id, { email }) lands in $set, not properties, so all
|
||||
* three bags must be sanitized.
|
||||
*
|
||||
* Ref: posthog.com/tutorials/web-redact-properties
|
||||
*/
|
||||
interface PostHogEventLike {
|
||||
properties?: Record<string, unknown>
|
||||
$set?: Record<string, unknown>
|
||||
$set_once?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPostHogBeforeSend() {
|
||||
return function beforeSend<E extends PostHogEventLike>(
|
||||
event: E | null
|
||||
): E | null {
|
||||
if (!event) return null
|
||||
stripPiiKeys(event.properties)
|
||||
stripPiiKeys(event.$set)
|
||||
stripPiiKeys(event.$set_once)
|
||||
return event
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
<audio
|
||||
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
||||
:src
|
||||
:src="audioSrc"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
/>
|
||||
@@ -192,6 +192,7 @@ const progressRef = ref<HTMLElement>()
|
||||
const {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying,
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { i18n, te } from '@/i18n'
|
||||
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import NodeTooltip from './NodeTooltip.vue'
|
||||
|
||||
type HitTest = (
|
||||
node: MockNode,
|
||||
x: number,
|
||||
y: number,
|
||||
offset: [number, number]
|
||||
) => number
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface MockNode {
|
||||
type: string
|
||||
flags: {
|
||||
collapsed?: boolean
|
||||
ghost?: boolean
|
||||
}
|
||||
pos: [number, number]
|
||||
inputs: Array<{ name: string }>
|
||||
constructor: {
|
||||
title_mode?: 0 | 1 | 2 | 3
|
||||
}
|
||||
}
|
||||
|
||||
interface MockCanvas {
|
||||
mouse: [number, number]
|
||||
graph_mouse: [number, number]
|
||||
node_over: MockNode | null
|
||||
getWidgetAtCursor: () => MockWidget | null
|
||||
}
|
||||
|
||||
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsDOMWidget = vi.hoisted(() =>
|
||||
vi.fn<(widget: MockWidget) => boolean>()
|
||||
)
|
||||
const mockCanvas = vi.hoisted(
|
||||
(): MockCanvas => ({
|
||||
mouse: [100, 80],
|
||||
graph_mouse: [10, 10],
|
||||
node_over: null,
|
||||
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LiteGraphModule>()
|
||||
return {
|
||||
...actual,
|
||||
isOverNodeInput: mockIsOverNodeInput,
|
||||
isOverNodeOutput: mockIsOverNodeOutput
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
isDOMWidget: mockIsDOMWidget
|
||||
}))
|
||||
|
||||
const jsonTooltip =
|
||||
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
|
||||
|
||||
const positiveCoordsTooltipKey =
|
||||
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
|
||||
|
||||
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
|
||||
|
||||
const sam3DetectNodeDef: ComfyNodeDef = {
|
||||
name: 'SAM3_Detect',
|
||||
display_name: 'SAM3 Detect',
|
||||
category: 'detection/',
|
||||
python_module: 'comfy_extras.nodes_sam3',
|
||||
description: '',
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
positive_coords: [
|
||||
'STRING',
|
||||
{
|
||||
tooltip: jsonTooltip,
|
||||
forceInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MASK'],
|
||||
output_name: ['masks'],
|
||||
output_tooltips: [jsonTooltip],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
function createSam3Node(): MockNode {
|
||||
return {
|
||||
type: 'SAM3_Detect',
|
||||
flags: {},
|
||||
pos: [0, 0],
|
||||
inputs: [{ name: 'positive_coords' }],
|
||||
constructor: {}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOutputTooltipMessage(tooltip: string | null) {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
nodeDefs: {
|
||||
SAM3_Detect: {
|
||||
outputs: {
|
||||
0: {
|
||||
tooltip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function renderAndHoverCanvas() {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(NodeTooltip)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
document.body.appendChild(canvas)
|
||||
await user.hover(canvas)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('NodeTooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 0 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockCanvas.mouse = [100, 80]
|
||||
mockCanvas.graph_mouse = [10, 10]
|
||||
mockCanvas.node_over = createSam3Node()
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
|
||||
|
||||
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
|
||||
mergeOutputTooltipMessage(jsonTooltip)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mergeOutputTooltipMessage(null)
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(outputTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
|
||||
name: 'positive_coords'
|
||||
})
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { stRaw } from '@/i18n'
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
LiteGraph,
|
||||
isOverNodeInput,
|
||||
@@ -84,7 +84,7 @@ function onIdle() {
|
||||
)
|
||||
if (inputSlot !== -1) {
|
||||
const inputName = node.inputs[inputSlot].name
|
||||
const translatedTooltip = stRaw(
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
|
||||
nodeDef?.inputs[inputName]?.tooltip ?? ''
|
||||
)
|
||||
@@ -98,7 +98,7 @@ function onIdle() {
|
||||
[0, 0]
|
||||
)
|
||||
if (outputSlot !== -1) {
|
||||
const translatedTooltip = stRaw(
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
|
||||
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
|
||||
)
|
||||
@@ -108,7 +108,7 @@ function onIdle() {
|
||||
const widget = comfyApp.canvas.getWidgetAtCursor()
|
||||
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
|
||||
if (widget && !isDOMWidget(widget)) {
|
||||
const translatedTooltip = stRaw(
|
||||
const translatedTooltip = st(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
|
||||
nodeDef?.inputs[widget.name]?.tooltip ?? ''
|
||||
)
|
||||
|
||||
@@ -44,14 +44,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
|
||||
v-if="canFitToViewer"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
>
|
||||
<div
|
||||
v-if="canFitToViewer || canCenterCameraOnModel"
|
||||
class="flex flex-col rounded-lg bg-backdrop/30"
|
||||
>
|
||||
<div class="flex flex-col rounded-lg bg-backdrop/30">
|
||||
<Button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
@@ -64,29 +61,25 @@
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.centerCameraOnModel'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.centerCameraOnModel')"
|
||||
@click="handleCenterCameraOnModel"
|
||||
>
|
||||
<i class="pi pi-compass text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ViewerControls
|
||||
v-if="enable3DViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
<div
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-24 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isPreview"
|
||||
class="pointer-events-auto absolute right-2 z-20"
|
||||
:class="{
|
||||
'top-24': !enable3DViewer,
|
||||
'top-36': enable3DViewer
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
v-if="!isPreview"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@@ -149,7 +142,6 @@ const {
|
||||
isRecording,
|
||||
isPreview,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
@@ -183,7 +175,6 @@ const {
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
|
||||
@@ -29,17 +29,47 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
te: vi.fn(() => false),
|
||||
st: vi.fn((_key: string, fallback: string) => fallback),
|
||||
t: vi.fn((key: string, params?: { count?: number }) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = params?.count ?? 0
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
return key
|
||||
})
|
||||
}))
|
||||
vi.mock('@/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'errorCatalog.validationErrors.required_input_missing.title':
|
||||
'Missing connection',
|
||||
'errorCatalog.validationErrors.required_input_missing.message':
|
||||
'Required input slots have no connection feeding them.',
|
||||
'errorCatalog.validationErrors.required_input_missing.details':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.itemLabel':
|
||||
'{nodeName} - {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastTitle':
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
|
||||
const interpolate = (
|
||||
message: string,
|
||||
params?: Record<string, string | number>
|
||||
) =>
|
||||
message.replace(/\{(\w+)\}/g, (match, paramName) =>
|
||||
params?.[paramName] === undefined ? match : String(params[paramName])
|
||||
)
|
||||
|
||||
return {
|
||||
te: vi.fn((key: string) => key in messages),
|
||||
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
|
||||
t: vi.fn((key: string, params?: Record<string, string | number>) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = Number(params?.count ?? 0)
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
|
||||
return interpolate(messages[key] ?? key, params)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => ({
|
||||
@@ -412,10 +442,16 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
if (execGroups[0].type !== 'execution') return
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
|
||||
'KSampler failed'
|
||||
)
|
||||
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
|
||||
message: 'RuntimeError: CUDA out of memory',
|
||||
details: 'line 1\nline 2',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
})
|
||||
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
|
||||
// bypass catalog display fields until targeted runtime handling lands.
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes prompt error when present', async () => {
|
||||
@@ -428,7 +464,8 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -417,12 +417,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const resolvedDisplay = resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName: e.node_type,
|
||||
isCloud
|
||||
})
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
@@ -433,8 +427,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolvedDisplay
|
||||
exceptionType: e.exception_type
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
|
||||
@@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
@@ -71,7 +74,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
const NodeSearchContentStub = defineComponent({
|
||||
name: 'NodeSearchContent',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
filters: { type: Array, default: () => [] },
|
||||
defaultRootFilter: { type: String, default: null }
|
||||
},
|
||||
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
|
||||
setup(_, { emit }) {
|
||||
@@ -79,7 +83,8 @@ describe('NodeSearchBoxPopover', () => {
|
||||
emit('addNode', nodeDef, dragEvent)
|
||||
return {}
|
||||
},
|
||||
template: '<div data-testid="search-content-v2"></div>'
|
||||
template:
|
||||
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
@@ -276,4 +281,75 @@ describe('NodeSearchBoxPopover', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultRootFilter on dialog open', () => {
|
||||
function setGraphNodes(nodes: unknown[]) {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.canvas = {
|
||||
graph: { nodes },
|
||||
allow_searchbox: false,
|
||||
setDirty: vi.fn(),
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
reset: vi.fn(),
|
||||
disconnectLinks: vi.fn()
|
||||
}
|
||||
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
|
||||
}
|
||||
|
||||
async function openSearch() {
|
||||
useSearchBoxStore().visible = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('defaults to Essentials when the graph is empty', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to Essentials when the canvas is not yet available', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to null when the graph has nodes', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-evaluates each time the dialog opens', async () => {
|
||||
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
|
||||
|
||||
setGraphNodes([])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
|
||||
'data-default-root-filter',
|
||||
RootCategory.Essentials
|
||||
)
|
||||
|
||||
useSearchBoxStore().visible = false
|
||||
await nextTick()
|
||||
setGraphNodes([{ id: 1 }])
|
||||
await openSearch()
|
||||
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
|
||||
'data-default-root-filter'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<div v-if="useSearchBoxV2" role="search" class="relative">
|
||||
<NodeSearchContent
|
||||
:filters="nodeFilters"
|
||||
:default-root-filter="defaultRootFilter"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
@add-node="addNode"
|
||||
@@ -66,7 +67,6 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -77,6 +77,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
@@ -88,6 +90,7 @@ let disconnectOnReset = false
|
||||
const settingStore = useSettingStore()
|
||||
const searchBoxStore = useSearchBoxStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
|
||||
|
||||
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
|
||||
@@ -103,6 +106,13 @@ const enableNodePreview = computed(
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
? RootCategory.Essentials
|
||||
: null
|
||||
})
|
||||
function getNewNodeLocation(): Point {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
@@ -127,16 +137,13 @@ function clearFilters() {
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
const node = litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
@@ -230,6 +231,48 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply defaultRootFilter when provided and category is available', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
display_name: 'Essential Node',
|
||||
essentials_category: 'basic'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'FrequentNode',
|
||||
display_name: 'Frequent Node'
|
||||
})
|
||||
])
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['FrequentNode']
|
||||
])
|
||||
|
||||
renderComponent({ defaultRootFilter: RootCategory.Essentials })
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
|
||||
@@ -141,8 +141,9 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
const { filters, defaultRootFilter = null } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
defaultRootFilter?: RootCategoryId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -193,8 +194,12 @@ function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(
|
||||
defaultRootFilter === RootCategory.Essentials &&
|
||||
!nodeAvailability.value.essential
|
||||
? null
|
||||
: defaultRootFilter
|
||||
)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
|
||||
@@ -65,7 +65,6 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
@@ -156,8 +155,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
const graphNode = useLitegraphService().addNodeOnGraph(
|
||||
provider.nodeDef
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
|
||||
@@ -189,7 +189,6 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import {
|
||||
DEFAULT_GROUPING_ID,
|
||||
@@ -322,11 +321,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
}
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -184,11 +183,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
await nodeBookmarkStore.addBookmark(nodePath)
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -27,20 +26,9 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
confirm: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
])
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, values?: { workflow?: string }) =>
|
||||
values?.workflow ? `${key}:${values.workflow}` : key,
|
||||
st: (key: string, fallback: string) => {
|
||||
const code = key.replace('auth.errors.', '')
|
||||
return knownAuthErrorCodes.has(code) ? key : fallback
|
||||
}
|
||||
values?.workflow ? `${key}:${values.workflow}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -84,7 +72,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
||||
) => action,
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
toastErrorHandler: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -205,46 +193,3 @@ describe('useAuthActions.logout', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions.reportError', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the friendly message for a known Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.auth/invalid-credential'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.generic'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates non-Firebase errors to toastErrorHandler', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
const networkError = new TypeError('Failed to fetch')
|
||||
|
||||
reportError(networkError)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { st, t } from '@/i18n'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -47,12 +47,6 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
|
||||
})
|
||||
} else {
|
||||
toastErrorHandler(error)
|
||||
}
|
||||
|
||||
@@ -258,34 +258,6 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': bypassed, '1': active }
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns false for empty selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
|
||||
app.canvas.selected_nodes = {}
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('getSelectedNodes should include nodes from subgraphs', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
|
||||
@@ -93,22 +93,6 @@ export function useSelectedLiteGraphItems() {
|
||||
return collectFromNodes(nodeArray)
|
||||
}
|
||||
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
Object.values(app.canvas.selected_nodes ?? {})
|
||||
|
||||
/**
|
||||
* True iff every selected node is in `mode`. Mirrors the predicate used by
|
||||
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
|
||||
* An empty selection returns `false` (no node is in the mode).
|
||||
*/
|
||||
const areAllSelectedNodesInMode = (mode: LGraphEventMode): boolean => {
|
||||
const selectedNodeArray = getSelectedNodesShallow()
|
||||
return (
|
||||
selectedNodeArray.length > 0 &&
|
||||
selectedNodeArray.every((node) => node.mode === mode)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the execution mode of all selected nodes
|
||||
*
|
||||
@@ -118,10 +102,18 @@ export function useSelectedLiteGraphItems() {
|
||||
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
|
||||
*/
|
||||
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
|
||||
const selectedNodeArray = getSelectedNodesShallow()
|
||||
const newModeForSelectedNode = areAllSelectedNodesInMode(mode)
|
||||
? LGraphEventMode.ALWAYS
|
||||
: mode
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
if (!selectedNodes) return
|
||||
|
||||
// Convert selected_nodes object to array
|
||||
const selectedNodeArray: LGraphNode[] = []
|
||||
for (const i in selectedNodes) {
|
||||
selectedNodeArray.push(selectedNodes[i])
|
||||
}
|
||||
const allNodesMatch = !selectedNodeArray.some(
|
||||
(selectedNode) => selectedNode.mode !== mode
|
||||
)
|
||||
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
|
||||
|
||||
for (const selectedNode of selectedNodeArray)
|
||||
selectedNode.mode = newModeForSelectedNode
|
||||
@@ -134,7 +126,6 @@ export function useSelectedLiteGraphItems() {
|
||||
hasSelectableItems,
|
||||
hasMultipleSelectableItems,
|
||||
getSelectedNodes,
|
||||
areAllSelectedNodesInMode,
|
||||
toggleSelectedNodesMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,51 +135,6 @@ describe('contextMenuConverter', () => {
|
||||
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
||||
})
|
||||
|
||||
it('blacklists the legacy Bypass push so Vue supplies the only item', () => {
|
||||
const legacyOptions = convertContextMenuToOptions(
|
||||
[{ content: 'Bypass', callback: () => {} }],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
expect(
|
||||
legacyOptions.find(
|
||||
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
|
||||
)
|
||||
).toBeUndefined()
|
||||
|
||||
const vueBypass: MenuOption = {
|
||||
label: 'Remove Bypass',
|
||||
icon: 'icon-[lucide--redo-dot]',
|
||||
shortcut: 'Ctrl+B',
|
||||
action: () => {},
|
||||
source: 'vue'
|
||||
}
|
||||
const result = buildStructuredMenu([...legacyOptions, vueBypass])
|
||||
|
||||
const bypassItems = result.filter(
|
||||
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
|
||||
)
|
||||
expect(bypassItems).toHaveLength(1)
|
||||
expect(bypassItems[0].source).toBe('vue')
|
||||
expect(bypassItems[0].shortcut).toBe('Ctrl+B')
|
||||
})
|
||||
|
||||
it('does not treat Bypass and Remove Bypass as label equivalents', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Bypass', action: () => {}, source: 'vue' },
|
||||
{ label: 'Remove Bypass', action: () => {}, source: 'litegraph' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
const labels = result
|
||||
.map((opt) => opt.label)
|
||||
.filter((l) => l === 'Bypass' || l === 'Remove Bypass')
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining(['Bypass', 'Remove Bypass'])
|
||||
)
|
||||
})
|
||||
|
||||
it('should recognize Frame Nodes as a core menu item', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
|
||||
@@ -21,10 +21,7 @@ const HARD_BLACKLIST = new Set([
|
||||
'Title',
|
||||
'Mode',
|
||||
'Properties Panel',
|
||||
'Copy (Clipspace)',
|
||||
// Vue getBypassOption supplies the single state-aware Bypass/Remove Bypass item
|
||||
'Bypass',
|
||||
'Remove Bypass'
|
||||
'Copy (Clipspace)'
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -248,9 +245,7 @@ const MENU_ORDER: string[] = [
|
||||
'Paste Image',
|
||||
'Save Image',
|
||||
'Copy (Clipspace)',
|
||||
'Paste (Clipspace)',
|
||||
// Fallback for other core items
|
||||
'Convert to Group Node (Deprecated)'
|
||||
'Paste (Clipspace)'
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,8 +45,7 @@ export interface SubMenuOption {
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
NEW = 'new',
|
||||
DEPRECATED = 'deprecated'
|
||||
NEW = 'new'
|
||||
}
|
||||
|
||||
// Global singleton for NodeOptions component reference
|
||||
@@ -212,7 +211,7 @@ export function useMoreOptionsMenu() {
|
||||
}
|
||||
if (!groupContext) {
|
||||
const pin = getPinOption(states, bump)
|
||||
const bypass = getBypassOption(bump)
|
||||
const bypass = getBypassOption(states, bump)
|
||||
options.push(pin)
|
||||
options.push(bypass)
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selected_nodes: null as Record<string, LGraphNode> | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const setSelectedNodes = (nodes: LGraphNode[]) => {
|
||||
const dict: Record<string, LGraphNode> = {}
|
||||
nodes.forEach((n, i) => {
|
||||
dict[String(i)] = n
|
||||
})
|
||||
mockApp.canvas.selected_nodes = dict
|
||||
}
|
||||
|
||||
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode =>
|
||||
({ id, mode }) as LGraphNode
|
||||
|
||||
const getBypassLabel = (): string => {
|
||||
let label = ''
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
const { getBypassOption } = useNodeMenuOptions()
|
||||
label = getBypassOption(() => {}).label ?? ''
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
render(Wrapper, { global: { plugins: [i18n] } })
|
||||
return label
|
||||
}
|
||||
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockApp.canvas.selected_nodes = null
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
setSelectedNodes([nodeWithMode(LGraphEventMode.ALWAYS, 1)])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
})
|
||||
|
||||
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Remove Bypass')
|
||||
})
|
||||
|
||||
it('labels as "Bypass" on mixed selection so it matches the toggle action', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.ALWAYS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
import { useSelectedNodeActions } from './useSelectedNodeActions'
|
||||
@@ -23,7 +20,6 @@ export function useNodeMenuOptions() {
|
||||
toggleNodeBypass,
|
||||
runBranch
|
||||
} = useSelectedNodeActions()
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
|
||||
const shapeSubmenu = computed(() =>
|
||||
shapeOptions.map((shape) => ({
|
||||
@@ -95,8 +91,11 @@ export function useNodeMenuOptions() {
|
||||
}
|
||||
})
|
||||
|
||||
const getBypassOption = (bump: () => void): MenuOption => ({
|
||||
label: areAllSelectedNodesInMode(LGraphEventMode.BYPASS)
|
||||
const getBypassOption = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: states.bypassed
|
||||
? t('contextMenu.Remove Bypass')
|
||||
: t('contextMenu.Bypass'),
|
||||
icon: 'icon-[lucide--redo-dot]',
|
||||
|
||||
@@ -72,14 +72,14 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
|
||||
expect(mocks.frameNodes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
|
||||
it('does not include a Convert to Group Node option', () => {
|
||||
const { getMultipleNodesOptions } = useSelectionMenuOptions()
|
||||
const options = getMultipleNodesOptions()
|
||||
|
||||
const groupNodeOption = options.find(
|
||||
(opt) => opt.label === 'contextMenu.Convert to Group Node'
|
||||
)
|
||||
expect(groupNodeOption).toBeDefined()
|
||||
expect(groupNodeOption).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import { useFrameNodes } from './useFrameNodes'
|
||||
import { BadgeVariant } from './useMoreOptionsMenu'
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
@@ -102,28 +100,13 @@ export function useSelectionMenuOptions() {
|
||||
return options
|
||||
}
|
||||
|
||||
const getMultipleNodesOptions = (): MenuOption[] => {
|
||||
const convertToGroupNodes = () => {
|
||||
const commandStore = useCommandStore()
|
||||
void commandStore.execute(
|
||||
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
|
||||
)
|
||||
const getMultipleNodesOptions = (): MenuOption[] => [
|
||||
{
|
||||
label: t('g.frameNodes'),
|
||||
icon: 'icon-[lucide--frame]',
|
||||
action: frameNodes
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Group Node'),
|
||||
icon: 'icon-[lucide--group]',
|
||||
action: convertToGroupNodes,
|
||||
badge: BadgeVariant.DEPRECATED
|
||||
},
|
||||
{
|
||||
label: t('g.frameNodes'),
|
||||
icon: 'icon-[lucide--frame]',
|
||||
action: frameNodes
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const getAlignmentOptions = (): MenuOption[] => [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -13,6 +13,7 @@ import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
export interface NodeSelectionState {
|
||||
collapsed: boolean
|
||||
pinned: boolean
|
||||
bypassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,10 +78,12 @@ export function useSelectionState() {
|
||||
const computeSelectionStatesFromNodes = (
|
||||
nodes: LGraphNode[]
|
||||
): NodeSelectionState => {
|
||||
if (!nodes.length) return { collapsed: false, pinned: false }
|
||||
if (!nodes.length)
|
||||
return { collapsed: false, pinned: false, bypassed: false }
|
||||
return {
|
||||
collapsed: nodes.some((n) => n.flags?.collapsed),
|
||||
pinned: nodes.some((n) => n.pinned)
|
||||
pinned: nodes.some((n) => n.pinned),
|
||||
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -38,8 +37,7 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
if (!draggedNode.value) return false
|
||||
const canvas = useCanvasStore().canvas
|
||||
if (!canvas) return false
|
||||
if (!isOverCanvas(clientX, clientY)) return false
|
||||
@@ -48,9 +46,7 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
|
||||
if (node) canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
@@ -147,11 +146,9 @@ export function useJobMenu(
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -38,9 +37,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
} else if (node.data instanceof ComfyModelDef) {
|
||||
const model = node.data
|
||||
const pos = basePos
|
||||
@@ -61,8 +58,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
if (!targetGraphNode) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
|
||||
targetGraphNode = litegraphService.addNodeOnGraph(
|
||||
provider.nodeDef,
|
||||
{
|
||||
pos
|
||||
}
|
||||
)
|
||||
targetProvider = provider
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -108,8 +107,7 @@ export function useFeatureFlags() {
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
|
||||
@@ -192,7 +192,6 @@ describe('useLoad3d', () => {
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}),
|
||||
getModelInfo: vi.fn().mockReturnValue(null),
|
||||
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
|
||||
setAnimationTime: vi.fn(),
|
||||
renderer: {
|
||||
@@ -1355,39 +1354,6 @@ describe('useLoad3d', () => {
|
||||
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
|
||||
})
|
||||
|
||||
it('gizmoTransformChange mirrors the live scene into Scene Config models', async () => {
|
||||
const modelTransform = {
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 3, y: 3, z: 3 }
|
||||
}
|
||||
vi.mocked(mockLoad3d.getModelInfo!).mockReturnValue(modelTransform)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
|
||||
const handler = addEventCalls.find(
|
||||
([event]) => event === 'gizmoTransformChange'
|
||||
)![1] as (data: unknown) => void
|
||||
|
||||
handler({
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 0.5, y: 0.6, z: 0.7 },
|
||||
scale: { x: 3, y: 3, z: 3 },
|
||||
enabled: true,
|
||||
mode: 'rotate'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(composable.sceneConfig.value.models).toEqual([modelTransform])
|
||||
const savedScene = mockNode.properties['Scene Config'] as {
|
||||
models: unknown[]
|
||||
}
|
||||
expect(savedScene.models).toEqual([modelTransform])
|
||||
})
|
||||
|
||||
it('should reset gizmo config on model switch (not first load)', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
@@ -132,7 +132,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const canFitToViewer = ref(true)
|
||||
const canCenterCameraOnModel = ref(false)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
const canExport = ref(true)
|
||||
@@ -790,11 +789,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const syncSceneModels = () => {
|
||||
const modelInfo = load3d?.getModelInfo()
|
||||
sceneConfig.value.models = modelInfo ? [modelInfo] : []
|
||||
}
|
||||
|
||||
const eventConfig = {
|
||||
materialModeChange: (value: string) => {
|
||||
modelConfig.value.materialMode = value as MaterialMode
|
||||
@@ -854,7 +848,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
|
||||
const caps = load3d?.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps?.fitToViewer ?? true
|
||||
canUseGizmo.value = caps?.gizmoTransform ?? true
|
||||
@@ -867,7 +860,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
]
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
applyGizmoConfigToLoad3d()
|
||||
syncSceneModels()
|
||||
isFirstModelLoad = false
|
||||
},
|
||||
modelReady: () => {
|
||||
@@ -944,7 +936,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
modelConfig.value.gizmo.enabled = data.enabled
|
||||
modelConfig.value.gizmo.mode = data.mode
|
||||
}
|
||||
syncSceneModels()
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -970,11 +961,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const transform = load3d.getGizmoTransform()
|
||||
modelConfig.value.gizmo.position = transform.position
|
||||
modelConfig.value.gizmo.scale = transform.scale
|
||||
syncSceneModels()
|
||||
}
|
||||
|
||||
const handleCenterCameraOnModel = () => {
|
||||
load3d?.centerCameraOnModel()
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
@@ -1017,7 +1003,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
@@ -1053,7 +1038,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -369,7 +368,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
isPreview.value = node.type === 'Preview3D'
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
@@ -108,6 +108,23 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
|
||||
mockFetchApi.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading, audioSrc } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 10
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(10)
|
||||
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMediaControls, whenever } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -19,6 +19,7 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const waveformRef = ref<HTMLElement>()
|
||||
const blobUrl = ref<string>()
|
||||
const loading = ref(false)
|
||||
let decodeRequestId = 0
|
||||
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
|
||||
@@ -34,6 +35,10 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(duration.value))
|
||||
|
||||
const audioSrc = computed(() =>
|
||||
src.value ? (blobUrl.value ?? src.value) : ''
|
||||
)
|
||||
|
||||
function generatePlaceholderBars(): WaveformBar[] {
|
||||
return Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * 60 + 10
|
||||
@@ -85,12 +90,22 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
|
||||
if (requestId !== decodeRequestId) return
|
||||
|
||||
const blob = new Blob([arrayBuffer.slice(0)], {
|
||||
type: response.headers.get('content-type') ?? 'audio/wav'
|
||||
})
|
||||
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = URL.createObjectURL(blob)
|
||||
|
||||
ctx = new AudioContext()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
if (requestId !== decodeRequestId) return
|
||||
generateBarsFromBuffer(audioBuffer)
|
||||
} catch {
|
||||
if (requestId === decodeRequestId) {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
bars.value = generatePlaceholderBars()
|
||||
}
|
||||
} finally {
|
||||
@@ -158,9 +173,19 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
decodeRequestId += 1
|
||||
audioRef.value?.pause()
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying: playing,
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
@@ -187,18 +185,11 @@ function dynamicComboWidget(
|
||||
//A little hacky, but onConfigure won't work.
|
||||
//It fires too late and is overly disruptive
|
||||
let widgetValue = widget.value
|
||||
const getState = () => {
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
if (!graphId) return undefined
|
||||
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
|
||||
}
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return getState()?.value ?? widgetValue
|
||||
return widgetValue
|
||||
},
|
||||
set(value) {
|
||||
const state = getState()
|
||||
if (state) state.value = value
|
||||
widgetValue = value
|
||||
updateWidgets(value)
|
||||
}
|
||||
|
||||
@@ -1833,38 +1833,6 @@ const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert selected nodes to a group node
|
||||
* @throws {Error} if no nodes are selected
|
||||
* @throws {Error} if a group node is already selected
|
||||
* @throws {Error} if a group node is selected
|
||||
*
|
||||
* The context menu item should not be available if any of the above conditions are met.
|
||||
* The error is automatically handled by the commandStore when the command is executed.
|
||||
*/
|
||||
async function convertSelectedNodesToGroupNode() {
|
||||
const nodes = Object.values(app.canvas.selected_nodes ?? {})
|
||||
if (nodes.length === 0) {
|
||||
throw new Error('No nodes selected')
|
||||
}
|
||||
if (nodes.length === 1) {
|
||||
throw new Error('Please select multiple nodes to convert to group node')
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
throw new Error('Selected nodes contain a subgraph node')
|
||||
}
|
||||
if (GroupNodeHandler.isGroupNode(node)) {
|
||||
throw new Error('Selected nodes contain a group node')
|
||||
}
|
||||
}
|
||||
return await GroupNodeHandler.fromNodes(nodes)
|
||||
}
|
||||
|
||||
const convertDisabled = (selected: LGraphNode[]) =>
|
||||
selected.length < 2 || !!selected.find((n) => GroupNodeHandler.isGroupNode(n))
|
||||
|
||||
function ungroupSelectedGroupNodes() {
|
||||
const nodes = Object.values(app.canvas.selected_nodes ?? {})
|
||||
for (const node of nodes) {
|
||||
@@ -1900,13 +1868,6 @@ let globalDefs: Record<string, ComfyNodeDef>
|
||||
const ext: ComfyExtension = {
|
||||
name: id,
|
||||
commands: [
|
||||
{
|
||||
id: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
|
||||
label: 'Convert selected nodes to group node',
|
||||
icon: 'pi pi-sitemap',
|
||||
versionAdded: '1.3.17',
|
||||
function: () => convertSelectedNodesToGroupNode()
|
||||
},
|
||||
{
|
||||
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
|
||||
label: 'Ungroup selected group nodes',
|
||||
@@ -1924,13 +1885,6 @@ const ext: ComfyExtension = {
|
||||
}
|
||||
],
|
||||
keybindings: [
|
||||
{
|
||||
commandId: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
|
||||
combo: {
|
||||
alt: true,
|
||||
key: 'g'
|
||||
}
|
||||
},
|
||||
{
|
||||
commandId: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
|
||||
combo: {
|
||||
@@ -1942,42 +1896,13 @@ const ext: ComfyExtension = {
|
||||
],
|
||||
|
||||
getCanvasMenuItems(canvas): IContextMenuValue[] {
|
||||
const items: IContextMenuValue[] = []
|
||||
const selected = Object.values(canvas.selected_nodes ?? {})
|
||||
const convertEnabled = !convertDisabled(selected)
|
||||
|
||||
items.push({
|
||||
content: `Convert to Group Node (Deprecated)`,
|
||||
disabled: !convertEnabled,
|
||||
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
|
||||
callback: async () => convertSelectedNodesToGroupNode()
|
||||
})
|
||||
|
||||
const groups = canvas.graph?.extra?.groupNodes
|
||||
const manageDisabled = !groups || !Object.keys(groups).length
|
||||
items.push({
|
||||
content: `Manage Group Nodes`,
|
||||
disabled: manageDisabled,
|
||||
callback: () => manageGroupNodes()
|
||||
})
|
||||
|
||||
return items
|
||||
},
|
||||
|
||||
getNodeMenuItems(node): IContextMenuValue[] {
|
||||
if (GroupNodeHandler.isGroupNode(node)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selected = Object.values(app.canvas.selected_nodes ?? {})
|
||||
const convertEnabled = !convertDisabled(selected)
|
||||
|
||||
return [
|
||||
{
|
||||
content: `Convert to Group Node (Deprecated)`,
|
||||
disabled: !convertEnabled,
|
||||
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
|
||||
callback: async () => convertSelectedNodesToGroupNode()
|
||||
content: `Manage Group Nodes`,
|
||||
disabled: manageDisabled,
|
||||
callback: () => manageGroupNodes()
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -164,7 +164,6 @@ function makeLoad3DNode(
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
|
||||
size: [300, 600],
|
||||
setSize: vi.fn(),
|
||||
addWidget: vi.fn(),
|
||||
widgets: overrides.widgets ?? [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
|
||||
@@ -6,8 +6,7 @@ import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
Model3DInfo
|
||||
CameraState
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import {
|
||||
@@ -219,15 +218,13 @@ useExtensionService().registerExtension({
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Load3D.PLYEngine',
|
||||
category: ['3D', 'PointCloud', 'Point Cloud Engine'],
|
||||
name: 'Point Cloud Engine',
|
||||
category: ['3D', 'PLY', 'PLY Engine'],
|
||||
name: 'PLY Engine',
|
||||
tooltip:
|
||||
'Select the engine for loading point cloud PLY files. "threejs" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). "fastply" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.',
|
||||
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
|
||||
type: 'combo',
|
||||
options: ['threejs', 'fastply'],
|
||||
options: ['threejs', 'fastply', 'sparkjs'],
|
||||
defaultValue: 'threejs',
|
||||
migrateDeprecatedValue: (value) =>
|
||||
value === 'sparkjs' ? 'threejs' : value,
|
||||
experimental: true
|
||||
}
|
||||
],
|
||||
@@ -269,45 +266,41 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
if (node.constructor.comfyClass === 'Load3D') {
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
|
||||
node.properties['Resource Folder'] = ''
|
||||
node.properties['Resource Folder'] = ''
|
||||
|
||||
fileInput.onchange = async () => {
|
||||
await handleModelUpload(fileInput.files!, node)
|
||||
}
|
||||
|
||||
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
|
||||
const resourcesInput = createFileInput('*', true)
|
||||
|
||||
resourcesInput.onchange = async () => {
|
||||
await handleResourcesUpload(resourcesInput.files!, node)
|
||||
resourcesInput.value = ''
|
||||
}
|
||||
|
||||
node.addWidget(
|
||||
'button',
|
||||
'upload extra resources',
|
||||
'uploadExtraResources',
|
||||
() => {
|
||||
resourcesInput.click()
|
||||
}
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model_file'
|
||||
)
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
}
|
||||
})
|
||||
fileInput.onchange = async () => {
|
||||
await handleModelUpload(fileInput.files!, node)
|
||||
}
|
||||
|
||||
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
|
||||
const resourcesInput = createFileInput('*', true)
|
||||
|
||||
resourcesInput.onchange = async () => {
|
||||
await handleResourcesUpload(resourcesInput.files!, node)
|
||||
resourcesInput.value = ''
|
||||
}
|
||||
|
||||
node.addWidget(
|
||||
'button',
|
||||
'upload extra resources',
|
||||
'uploadExtraResources',
|
||||
() => {
|
||||
resourcesInput.click()
|
||||
}
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
}
|
||||
})
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node: node,
|
||||
name: 'image',
|
||||
@@ -409,9 +402,6 @@ useExtensionService().registerExtension({
|
||||
|
||||
currentLoad3d.handleResize()
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
const returnVal = {
|
||||
image: `threed/${data.name} [temp]`,
|
||||
mask: `threed/${dataMask.name} [temp]`,
|
||||
@@ -419,8 +409,7 @@ useExtensionService().registerExtension({
|
||||
camera_info:
|
||||
(node.properties['Camera Config'] as CameraConfig | undefined)
|
||||
?.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
recording: ''
|
||||
}
|
||||
|
||||
const recordingData = currentLoad3d.getRecordingData()
|
||||
|
||||
@@ -162,42 +162,6 @@ describe('CameraManager', () => {
|
||||
const snapshot = manager.getCameraState()
|
||||
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
|
||||
})
|
||||
|
||||
it('captures the active camera orientation as a serializable quaternion', () => {
|
||||
manager.perspectiveCamera.position.set(5, 0, 0)
|
||||
manager.perspectiveCamera.lookAt(0, 0, 0)
|
||||
|
||||
const { quaternion } = manager.getCameraState()
|
||||
|
||||
expect(quaternion).toEqual({
|
||||
x: manager.perspectiveCamera.quaternion.x,
|
||||
y: manager.perspectiveCamera.quaternion.y,
|
||||
z: manager.perspectiveCamera.quaternion.z,
|
||||
w: manager.perspectiveCamera.quaternion.w
|
||||
})
|
||||
expect(Object.keys(quaternion ?? {})).not.toContain('_x')
|
||||
})
|
||||
|
||||
it('captures the configured perspective fov regardless of active camera', () => {
|
||||
manager.perspectiveCamera.fov = 42
|
||||
manager.toggleCamera('orthographic')
|
||||
|
||||
expect(manager.getCameraState().fov).toBe(42)
|
||||
})
|
||||
|
||||
it('reflects the perspective aspect after a resize', () => {
|
||||
manager.handleResize(800, 400)
|
||||
|
||||
expect(manager.getCameraState().aspect).toBe(2)
|
||||
})
|
||||
|
||||
it('reflects the orthographic frustum bounds after a resize', () => {
|
||||
manager.toggleCamera('orthographic')
|
||||
manager.handleResize(800, 400)
|
||||
|
||||
const { frustum } = manager.getCameraState()
|
||||
expect(frustum).toEqual({ left: -10, right: 10, top: 5, bottom: -5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('setControls', () => {
|
||||
|
||||
@@ -144,10 +144,6 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
|
||||
getCameraState(): CameraState {
|
||||
const { x, y, z, w } = this.activeCamera.quaternion
|
||||
const activeCamera = this.activeCamera as
|
||||
| THREE.PerspectiveCamera
|
||||
| THREE.OrthographicCamera
|
||||
return {
|
||||
position: this.activeCamera.position.clone(),
|
||||
target: this.controls?.target.clone() || new THREE.Vector3(),
|
||||
@@ -155,18 +151,7 @@ export class CameraManager implements CameraManagerInterface {
|
||||
this.activeCamera instanceof THREE.OrthographicCamera
|
||||
? this.activeCamera.zoom
|
||||
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
|
||||
cameraType: this.getCurrentCameraType(),
|
||||
quaternion: { x, y, z, w },
|
||||
fov: this.perspectiveCamera.fov,
|
||||
aspect: this.perspectiveCamera.aspect,
|
||||
near: activeCamera.near,
|
||||
far: activeCamera.far,
|
||||
frustum: {
|
||||
left: this.orthographicCamera.left,
|
||||
right: this.orthographicCamera.right,
|
||||
top: this.orthographicCamera.top,
|
||||
bottom: this.orthographicCamera.bottom
|
||||
}
|
||||
cameraType: this.getCurrentCameraType()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,30 +314,6 @@ describe('GizmoManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getModelInfo', () => {
|
||||
it('returns the full transform payload for the target object', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.name = 'my-model'
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(4, 5, 6)
|
||||
manager.setupForModel(model)
|
||||
|
||||
const info = manager.getModelInfo()
|
||||
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(info!.quaternion.w).toBeCloseTo(model.quaternion.w)
|
||||
expect(info!.scale).toEqual({ x: 4, y: 5, z: 6 })
|
||||
})
|
||||
|
||||
it('returns null when there is no target', () => {
|
||||
manager.init()
|
||||
expect(manager.getModelInfo()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFromScene / ensureHelperInScene', () => {
|
||||
it('removes helper from scene', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
|
||||
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { GizmoMode, Model3DTransform } from './interfaces'
|
||||
import type { GizmoMode } from './interfaces'
|
||||
|
||||
export class GizmoManager {
|
||||
private transformControls: TransformControls | null = null
|
||||
@@ -215,30 +215,6 @@ export class GizmoManager {
|
||||
}
|
||||
}
|
||||
|
||||
getModelInfo(): Model3DTransform | null {
|
||||
const object = this.targetObject
|
||||
if (!object) return null
|
||||
|
||||
return {
|
||||
position: {
|
||||
x: object.position.x,
|
||||
y: object.position.y,
|
||||
z: object.position.z
|
||||
},
|
||||
quaternion: {
|
||||
x: object.quaternion.x,
|
||||
y: object.quaternion.y,
|
||||
z: object.quaternion.z,
|
||||
w: object.quaternion.w
|
||||
},
|
||||
scale: {
|
||||
x: object.scale.x,
|
||||
y: object.scale.y,
|
||||
z: object.scale.z
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.transformControls) {
|
||||
const helper = this.transformControls.getHelper()
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
Load3DOptions,
|
||||
LoadModelOptions,
|
||||
MaterialMode,
|
||||
Model3DTransform,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
@@ -161,23 +160,11 @@ class Load3d {
|
||||
this.handleResize()
|
||||
this.startAnimation()
|
||||
|
||||
this.eventManager.addEventListener('modelReady', () => {
|
||||
if (this.adapterRef.current?.kind !== 'splat') return
|
||||
void this.repaintWhenSparkPaintable()
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.forceRender()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private async repaintWhenSparkPaintable(): Promise<void> {
|
||||
const sortComplete = this.sceneManager.awaitNextSparkDirty()
|
||||
this.forceRender()
|
||||
await sortComplete
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
private initResizeObserver(container: Element | HTMLElement): void {
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
@@ -638,7 +625,7 @@ class Load3d {
|
||||
}
|
||||
|
||||
getCurrentModelCapabilities(): ModelAdapterCapabilities {
|
||||
return this.adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
@@ -927,31 +914,11 @@ class Load3d {
|
||||
return this.gizmoManager.getTransform()
|
||||
}
|
||||
|
||||
public getModelInfo(): Model3DTransform | null {
|
||||
return this.gizmoManager.getModelInfo()
|
||||
}
|
||||
|
||||
public fitToViewer(): void {
|
||||
this.modelManager.fitToViewer()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public centerCameraOnModel(): void {
|
||||
const bounds = this.modelManager.getCurrentBounds()
|
||||
if (!bounds || bounds.isEmpty()) return
|
||||
|
||||
const center = bounds.getCenter(new THREE.Vector3())
|
||||
const camera = this.cameraManager.activeCamera
|
||||
const controls = this.controlsManager.controls
|
||||
const offset = center.clone().sub(camera.position)
|
||||
|
||||
camera.position.add(offset)
|
||||
controls.target.add(offset)
|
||||
camera.updateMatrixWorld(true)
|
||||
controls.update()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
|
||||
@@ -7,11 +7,7 @@ import type {
|
||||
ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
|
||||
function makeEventManagerStub() {
|
||||
return {
|
||||
@@ -32,12 +28,6 @@ type ModelManagerStub = {
|
||||
originalURL: string | null
|
||||
}
|
||||
|
||||
const STUB_CAPS = {} as ModelAdapterCapabilities
|
||||
const loadResult = (object: THREE.Object3D) => ({
|
||||
object,
|
||||
capabilities: STUB_CAPS
|
||||
})
|
||||
|
||||
function makeModelManagerStub(): ModelManagerStub {
|
||||
return {
|
||||
clearModel: vi.fn(),
|
||||
@@ -51,21 +41,14 @@ function makeModelManagerStub(): ModelManagerStub {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
meshLoad,
|
||||
splatLoad,
|
||||
pointCloudLoad,
|
||||
fetchModelDataMock,
|
||||
isGaussianSplatPLYMock,
|
||||
addAlert
|
||||
} = vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
fetchModelDataMock: vi.fn<() => Promise<ArrayBuffer>>(),
|
||||
isGaussianSplatPLYMock: vi.fn<(b: ArrayBuffer) => Promise<boolean>>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
|
||||
vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
getPLYEngineMock: vi.fn<() => string>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./MeshModelAdapter', () => ({
|
||||
MeshModelAdapter: class {
|
||||
@@ -82,35 +65,19 @@ vi.mock('./PointCloudModelAdapter', () => ({
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities = {}
|
||||
load = pointCloudLoad
|
||||
}
|
||||
},
|
||||
getPLYEngine: () => getPLYEngineMock()
|
||||
}))
|
||||
|
||||
vi.mock('./SplatModelAdapter', () => ({
|
||||
SplatModelAdapter: class {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities = {}
|
||||
matches = async (
|
||||
ext: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<boolean> => {
|
||||
if (ext !== 'ply') return true
|
||||
return isGaussianSplatPLYMock(await fetchBytes())
|
||||
}
|
||||
load = splatLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./ModelAdapter', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./ModelAdapter')>('./ModelAdapter')
|
||||
return { ...actual, fetchModelData: fetchModelDataMock }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/metadata/ply', () => ({
|
||||
isGaussianSplatPLY: isGaussianSplatPLYMock
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
@@ -120,10 +87,7 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
}))
|
||||
|
||||
type LoaderManagerInternals = {
|
||||
pickAdapter(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelAdapter | null>
|
||||
pickAdapter(extension: string): ModelAdapter | null
|
||||
}
|
||||
|
||||
function makeLoaderManager() {
|
||||
@@ -134,21 +98,21 @@ function makeLoaderManager() {
|
||||
eventManager
|
||||
)
|
||||
const internals = lm as unknown as LoaderManagerInternals
|
||||
const pick = (ext: string) =>
|
||||
internals.pickAdapter.call(lm, ext, () =>
|
||||
fetchModelDataMock()
|
||||
) as Promise<ModelAdapter | null>
|
||||
return { lm, modelManager, eventManager, pick }
|
||||
return {
|
||||
lm,
|
||||
modelManager,
|
||||
eventManager,
|
||||
pick: internals.pickAdapter.bind(lm)
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoaderManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
meshLoad.mockResolvedValue(null)
|
||||
splatLoad.mockResolvedValue(null)
|
||||
pointCloudLoad.mockResolvedValue(null)
|
||||
fetchModelDataMock.mockResolvedValue(new ArrayBuffer(0))
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
describe('getCurrentAdapter', () => {
|
||||
@@ -159,7 +123,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('exposes the picked adapter after a successful load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -168,7 +132,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('resets to null at the start of a new load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
@@ -180,7 +144,7 @@ describe('LoaderManager', () => {
|
||||
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
@@ -231,10 +195,7 @@ describe('LoaderManager', () => {
|
||||
}
|
||||
|
||||
let adapterDuringClear: ModelAdapter | null | undefined
|
||||
const adapterRef = {
|
||||
current: oldAdapter as ModelAdapter | null,
|
||||
capabilities: oldAdapter.capabilities as ModelAdapterCapabilities | null
|
||||
}
|
||||
const adapterRef = { current: oldAdapter as ModelAdapter | null }
|
||||
const lm = new LoaderManager(
|
||||
modelManager,
|
||||
eventManager,
|
||||
@@ -262,8 +223,8 @@ describe('LoaderManager', () => {
|
||||
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
|
||||
resolveSplatLoad = resolve
|
||||
})
|
||||
splatLoad.mockReturnValueOnce(slowSplatLoad.then(loadResult))
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
splatLoad.mockReturnValueOnce(slowSplatLoad)
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
const aPromise = lm.loadModel('api/view?filename=a.splat')
|
||||
|
||||
@@ -282,36 +243,42 @@ describe('LoaderManager', () => {
|
||||
describe('pickAdapter', () => {
|
||||
it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
|
||||
'routes %s to the mesh adapter',
|
||||
async (ext) => {
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick(ext))?.kind).toBe('mesh')
|
||||
expect(pick(ext)?.kind).toBe('mesh')
|
||||
}
|
||||
)
|
||||
|
||||
it.for(['spz', 'splat', 'ksplat'])(
|
||||
'routes %s to the splat adapter',
|
||||
async (ext) => {
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick(ext))?.kind).toBe('splat')
|
||||
expect(pick(ext)?.kind).toBe('splat')
|
||||
}
|
||||
)
|
||||
|
||||
it('routes .ply to the splat adapter when the bytes look like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(true)
|
||||
it('routes .ply to the point-cloud adapter for the default three engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick('ply'))?.kind).toBe('splat')
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('falls back to the point-cloud adapter for .ply that is not 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('fastply')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick('ply'))?.kind).toBe('pointCloud')
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', async () => {
|
||||
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(await pick('xyz')).toBeNull()
|
||||
expect(await pick('')).toBeNull()
|
||||
expect(pick('ply')?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('xyz')).toBeNull()
|
||||
expect(pick('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -381,7 +348,7 @@ describe('LoaderManager', () => {
|
||||
it('passes setupModel the object returned by the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const loaded = new THREE.Object3D()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(loaded))
|
||||
meshLoad.mockResolvedValueOnce(loaded)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -399,7 +366,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('emits modelLoadingEnd when the load completes', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -411,7 +378,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('forwards a decoded path and filename to the adapter', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
|
||||
@@ -423,105 +390,32 @@ describe('LoaderManager', () => {
|
||||
registerOriginalMaterial: expect.any(Function)
|
||||
}),
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=',
|
||||
'cube.glb',
|
||||
expect.any(Function)
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the path to type=input when no type param is given', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'cube.glb',
|
||||
expect.any(Function)
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes .ply to the point-cloud adapter when the header does not look like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { lm } = makeLoaderManager()
|
||||
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(pointCloudLoad).toHaveBeenCalled()
|
||||
expect(splatLoad).not.toHaveBeenCalled()
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('reroutes .ply through the splat adapter when the header looks like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(true)
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(splatLoad).toHaveBeenCalled()
|
||||
expect(pointCloudLoad).not.toHaveBeenCalled()
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('shares a single fetch between matches() and load() so .ply is not re-downloaded', async () => {
|
||||
const buf = new ArrayBuffer(16)
|
||||
fetchModelDataMock.mockResolvedValueOnce(buf)
|
||||
isGaussianSplatPLYMock.mockResolvedValue(true)
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
// Adapter receives a fetchBytes function (memoized), not bytes directly.
|
||||
expect(splatLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
'scan.ply',
|
||||
expect.any(Function)
|
||||
)
|
||||
// matches() called fetchBytes once; load()'s call hit the cached promise.
|
||||
expect(fetchModelDataMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispatches .ply via the adapter matches() tiebreaker, not extension order — a splat adapter whose matches() returns false yields to point-cloud', async () => {
|
||||
const modelManager =
|
||||
makeModelManagerStub() as unknown as ConstructorParameters<
|
||||
typeof LoaderManager
|
||||
>[0]
|
||||
const eventManager = makeEventManagerStub()
|
||||
// A splat adapter that ALSO claims '.ply' and is listed first.
|
||||
// Without matches(), it would short-circuit. With matches() returning
|
||||
// false (not a 3DGS PLY), the dispatcher must skip to the next
|
||||
// candidate (point cloud).
|
||||
const splatAdapter = {
|
||||
kind: 'splat' as const,
|
||||
extensions: ['ply', 'spz', 'splat', 'ksplat'] as const,
|
||||
capabilities: {} as never,
|
||||
matches: async (ext: string, fetchBytes: () => Promise<ArrayBuffer>) =>
|
||||
ext === 'ply' ? isGaussianSplatPLYMock(await fetchBytes()) : true,
|
||||
load: splatLoad
|
||||
}
|
||||
const pointCloudAdapter = {
|
||||
kind: 'pointCloud' as const,
|
||||
extensions: ['ply'] as const,
|
||||
capabilities: {} as never,
|
||||
load: pointCloudLoad
|
||||
}
|
||||
const lm = new LoaderManager(modelManager, eventManager, [
|
||||
splatAdapter,
|
||||
pointCloudAdapter
|
||||
])
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(pointCloudLoad).toHaveBeenCalled()
|
||||
expect(splatLoad).not.toHaveBeenCalled()
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
|
||||
@@ -604,8 +498,8 @@ describe('LoaderManager', () => {
|
||||
secondModel.name = 'second'
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad.then(loadResult))
|
||||
.mockResolvedValueOnce(loadResult(secondModel))
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(secondModel)
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
@@ -4,14 +4,9 @@ import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import { createAdapterRef, fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
AdapterRef,
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
|
||||
import { createAdapterRef } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
@@ -41,16 +36,14 @@ function isNotFoundError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default adapter set: mesh + splat + pointCloud. Each adapter declares the
|
||||
* file extensions it owns. For shared extensions (.ply), the adapter with an
|
||||
* async `matches()` tiebreaker is tried first; the unconditional adapter acts
|
||||
* as the fallback — so SplatModelAdapter precedes PointCloudModelAdapter.
|
||||
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
|
||||
* file extensions it owns; LoaderManager picks one by extension.
|
||||
*/
|
||||
function defaultAdapters(): ModelAdapter[] {
|
||||
return [
|
||||
new MeshModelAdapter(),
|
||||
new SplatModelAdapter(),
|
||||
new PointCloudModelAdapter()
|
||||
new PointCloudModelAdapter(),
|
||||
new SplatModelAdapter()
|
||||
]
|
||||
}
|
||||
|
||||
@@ -93,7 +86,6 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this.adapterRef.capabilities = null
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
|
||||
@@ -130,8 +122,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
// can't clobber adapterRef.current that a newer load already
|
||||
// wrote (or cleared).
|
||||
this.adapterRef.current = result.adapter
|
||||
this.adapterRef.capabilities = result.capabilities
|
||||
await this.modelManager.setupModel(result.object)
|
||||
await this.modelManager.setupModel(result.model)
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
@@ -146,18 +137,19 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private async pickAdapter(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelAdapter | null> {
|
||||
const candidates = this.adapters.filter((a) =>
|
||||
a.extensions.includes(extension)
|
||||
private pickAdapter(extension: string): ModelAdapter | null {
|
||||
const match = this.adapters.find((adapter) =>
|
||||
adapter.extensions.includes(extension)
|
||||
)
|
||||
for (const adapter of candidates) {
|
||||
if (!adapter.matches) return adapter
|
||||
if (await adapter.matches(extension, fetchBytes)) return adapter
|
||||
if (!match) return null
|
||||
|
||||
// PLY may be routed through the splat adapter when the PLYEngine setting
|
||||
// is sparkjs. Only honor the routing when both adapters are registered.
|
||||
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
|
||||
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
|
||||
if (splat) return splat
|
||||
}
|
||||
return null
|
||||
return match
|
||||
}
|
||||
|
||||
private createLoadContext(): ModelLoadContext {
|
||||
@@ -178,11 +170,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
private async loadModelInternal(
|
||||
url: string,
|
||||
fileExtension: string
|
||||
): Promise<{
|
||||
object: THREE.Object3D
|
||||
adapter: ModelAdapter
|
||||
capabilities: ModelAdapterCapabilities
|
||||
} | null> {
|
||||
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
const filename = params.get('filename')
|
||||
|
||||
@@ -200,24 +188,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
encodeURIComponent(subfolder) +
|
||||
'&filename='
|
||||
|
||||
let bytesPromise: Promise<ArrayBuffer> | null = null
|
||||
const fetchBytes = () => (bytesPromise ??= fetchModelData(path, filename))
|
||||
|
||||
const adapter = await this.pickAdapter(fileExtension, fetchBytes)
|
||||
const adapter = this.pickAdapter(fileExtension)
|
||||
if (!adapter) return null
|
||||
|
||||
const loadResult = await adapter.load(
|
||||
this.createLoadContext(),
|
||||
path,
|
||||
filename,
|
||||
fetchBytes
|
||||
)
|
||||
return loadResult
|
||||
? {
|
||||
object: loadResult.object,
|
||||
capabilities: loadResult.capabilities,
|
||||
adapter
|
||||
}
|
||||
: null
|
||||
const model = await adapter.load(this.createLoadContext(), path, filename)
|
||||
return model ? { model, adapter } : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ describe('MeshModelAdapter', () => {
|
||||
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.object.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('MeshModelAdapter', () => {
|
||||
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result!.object).toBe(fbxModel)
|
||||
expect(result).toBe(fbxModel)
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children', async () => {
|
||||
@@ -224,7 +224,7 @@ describe('MeshModelAdapter', () => {
|
||||
'cube.obj'
|
||||
)
|
||||
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('MeshModelAdapter', () => {
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
|
||||
expect(computeNormals).toHaveBeenCalled()
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result!.object).toBe(scene)
|
||||
expect(result).toBe(scene)
|
||||
})
|
||||
|
||||
it('also handles .gltf filenames', async () => {
|
||||
|
||||
@@ -11,8 +11,7 @@ import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext,
|
||||
ModelLoadResult
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class MeshModelAdapter implements ModelAdapter {
|
||||
@@ -46,18 +45,20 @@ export class MeshModelAdapter implements ModelAdapter {
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<ModelLoadResult | null> {
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
const object = await (extension === 'stl'
|
||||
? this.loadSTL(ctx, path, filename)
|
||||
: extension === 'fbx'
|
||||
? this.loadFBX(ctx, path, filename)
|
||||
: extension === 'obj'
|
||||
? this.loadOBJ(ctx, path, filename)
|
||||
: extension === 'gltf' || extension === 'glb'
|
||||
? this.loadGLTF(ctx, path, filename)
|
||||
: Promise.resolve(null))
|
||||
return object ? { object, capabilities: this.capabilities } : null
|
||||
switch (extension) {
|
||||
case 'stl':
|
||||
return this.loadSTL(ctx, path, filename)
|
||||
case 'fbx':
|
||||
return this.loadFBX(ctx, path, filename)
|
||||
case 'obj':
|
||||
return this.loadOBJ(ctx, path, filename)
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
return this.loadGLTF(ctx, path, filename)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async loadSTL(
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ModelLoadContext {
|
||||
readonly materialMode: MaterialMode
|
||||
}
|
||||
|
||||
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
|
||||
|
||||
export interface ModelAdapterCapabilities {
|
||||
/**
|
||||
@@ -65,59 +65,24 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by `ModelAdapter.load()`. Capabilities ride with the model
|
||||
* because some adapters (notably PLY) produce different capability sets
|
||||
* depending on the file contents — face-less point clouds expose only the
|
||||
* 'pointCloud' material mode, indexed meshes expose the full set. Keeping
|
||||
* capabilities per-load (not per-adapter) prevents stale state on the
|
||||
* adapter instance between two successive loads.
|
||||
* Mutable handle to the currently active ModelAdapter. A single ref is
|
||||
* created in `createLoad3d` and shared between LoaderManager (writer) and
|
||||
* SceneModelManager + Load3d (readers), so capability/bounds/dispose lookups
|
||||
* don't depend on construction order between those collaborators.
|
||||
*/
|
||||
export type ModelLoadResult = {
|
||||
object: THREE.Object3D
|
||||
capabilities: ModelAdapterCapabilities
|
||||
}
|
||||
export type AdapterRef = { current: ModelAdapter | null }
|
||||
|
||||
/**
|
||||
* Mutable handle to the currently active ModelAdapter plus the capabilities
|
||||
* reported by its most recent load. A single ref is created in `createLoad3d`
|
||||
* and shared between LoaderManager (writer) and SceneModelManager + Load3d
|
||||
* (readers), so capability/bounds/dispose lookups don't depend on
|
||||
* construction order between those collaborators.
|
||||
*/
|
||||
export type AdapterRef = {
|
||||
current: ModelAdapter | null
|
||||
capabilities: ModelAdapterCapabilities | null
|
||||
}
|
||||
|
||||
export const createAdapterRef = (): AdapterRef => ({
|
||||
current: null,
|
||||
capabilities: null
|
||||
})
|
||||
export const createAdapterRef = (): AdapterRef => ({ current: null })
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly kind: ModelAdapterKind
|
||||
readonly extensions: readonly string[]
|
||||
/**
|
||||
* Default capabilities for this adapter family. `load()` may return a
|
||||
* narrowed set for a specific model — read `adapterRef.capabilities` for
|
||||
* the live per-model value rather than this.
|
||||
*/
|
||||
readonly capabilities: ModelAdapterCapabilities
|
||||
/**
|
||||
* Async tiebreaker when multiple adapters claim the same extension
|
||||
* (e.g. .ply is shared by Gaussian splats and classic point clouds).
|
||||
* Adapters that uniquely own their extensions can omit this.
|
||||
*/
|
||||
matches?(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<boolean>
|
||||
load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string,
|
||||
fetchBytes?: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelLoadResult | null>
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null>
|
||||
/**
|
||||
* Optional. Return a world-space AABB for the given model. Adapters for
|
||||
* renderers whose geometry is not walked by `Box3.setFromObject` (e.g.
|
||||
|
||||