mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 09:09:14 +00:00
Compare commits
22 Commits
DynamicGro
...
core/1.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f577f5f65b | ||
|
|
049befb699 | ||
|
|
06633fd90d | ||
|
|
edace7ac10 | ||
|
|
99e0bf7022 | ||
|
|
ba2abda023 | ||
|
|
c53f408ea2 | ||
|
|
348e0fbfee | ||
|
|
552f6e6325 | ||
|
|
73d6e8cb0a | ||
|
|
ba9feb801a | ||
|
|
83ee4bbe2d | ||
|
|
1357d3f212 | ||
|
|
1fe3e5c50c | ||
|
|
99aa4a4171 | ||
|
|
80eb676518 | ||
|
|
8b6b434387 | ||
|
|
e7acb2f688 | ||
|
|
c1c1fc6673 | ||
|
|
37bdf7a530 | ||
|
|
d577f2a0ea | ||
|
|
06e4e98b52 |
2
.github/actions/setup-frontend/action.yaml
vendored
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -29,3 +29,5 @@ runs:
|
||||
if: ${{ inputs.include_build_step == 'true' }}
|
||||
shell: bash
|
||||
run: pnpm build
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
2
.github/workflows/ci-tests-e2e.yaml
vendored
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -47,6 +47,8 @@ jobs:
|
||||
|
||||
- name: Build cloud frontend
|
||||
run: pnpm build:cloud
|
||||
env:
|
||||
VITE_USE_LEGACY_DEFAULT_GRAPH: 'true'
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["cloud_importable_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [560, 100],
|
||||
"size": [400, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "cloud_importable_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class VueNodeHelpers {
|
||||
* Matches against the actual title element, not the full node body.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
getNodeByTitle(title: string | RegExp): Locator {
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.getByTestId('node-title').filter({ hasText: title })
|
||||
})
|
||||
@@ -145,7 +145,7 @@ export class VueNodeHelpers {
|
||||
/**
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
*/
|
||||
async getNodeIdByTitle(title: string): Promise<string> {
|
||||
async getNodeIdByTitle(title: string | RegExp): Promise<string> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -163,7 +163,7 @@ export class VueNodeHelpers {
|
||||
* Return a DOM-focused VueNodeFixture for the first node matching the title.
|
||||
* Resolves the node id up front so subsequent interactions survive title changes.
|
||||
*/
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
async getFixtureByTitle(title: string | RegExp): Promise<VueNodeFixture> {
|
||||
const nodeId = await this.getNodeIdByTitle(title)
|
||||
return new VueNodeFixture(this.getNodeLocator(nodeId))
|
||||
}
|
||||
|
||||
@@ -69,6 +69,24 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const customTemplatesHandler = async (route: Route) => {
|
||||
const customTemplates: Record<string, string[]> = {}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(customTemplates),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const customTemplatesPattern = '**/api/workflow_templates'
|
||||
this.routeHandlers.push({
|
||||
pattern: customTemplatesPattern,
|
||||
handler: customTemplatesHandler
|
||||
})
|
||||
await this.page.route(customTemplatesPattern, customTemplatesHandler)
|
||||
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
|
||||
@@ -60,14 +60,16 @@ export const TestIds = {
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
missingModelImport: 'missing-model-import',
|
||||
missingModelImportableRows: 'missing-model-importable-rows',
|
||||
missingModelLocate: 'missing-model-locate',
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelReferenceCount: 'missing-model-reference-count',
|
||||
missingModelUnsupportedSection:
|
||||
'missing-model-import-not-supported-section',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingModelRefresh: 'missing-model-header-refresh',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
swapNodeGroupCount: 'swap-node-group-count',
|
||||
|
||||
45
browser_tests/fixtures/utils/flashDetector.ts
Normal file
45
browser_tests/fixtures/utils/flashDetector.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
function flagAttributeFor(testId: string) {
|
||||
const encoded = Array.from(testId, (ch) =>
|
||||
ch.charCodeAt(0).toString(16)
|
||||
).join('')
|
||||
return `data-flashed-${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the first time an element matching `[data-testid="<testId>"]` is
|
||||
* present and rendered, sampled every frame via `requestAnimationFrame` from
|
||||
* page load. Catches a dialog that mounts and unmounts within a few frames,
|
||||
* which `toBeHidden()` (final state only) cannot.
|
||||
*
|
||||
* Must be called before navigation (e.g. before `comfyPage.setup()`).
|
||||
*/
|
||||
export async function trackElementFlash(
|
||||
page: Page,
|
||||
testId: string
|
||||
): Promise<{ hasFlashed: () => Promise<boolean> }> {
|
||||
const flagAttribute = flagAttributeFor(testId)
|
||||
|
||||
await page.addInitScript(
|
||||
({ id, attribute }: { id: string; attribute: string }) => {
|
||||
const sample = () => {
|
||||
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
|
||||
if (el instanceof HTMLElement) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
document.documentElement.setAttribute(attribute, 'true')
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ id: testId, attribute: flagAttribute }
|
||||
)
|
||||
|
||||
return {
|
||||
hasFlashed: async () =>
|
||||
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
}
|
||||
|
||||
async function setLocaleAndWaitForWorkflowReload(
|
||||
comfyPage: ComfyPage,
|
||||
locale: string
|
||||
) {
|
||||
await comfyPage.page.evaluate(async (targetLocale) => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('No active workflow while waiting for locale reload')
|
||||
}
|
||||
|
||||
const changeTracker = workflow.changeTracker.constructor as unknown as {
|
||||
isLoadingGraph: boolean
|
||||
}
|
||||
|
||||
let sawLoading = false
|
||||
const waitForReload = new Promise<void>((resolve, reject) => {
|
||||
const timeoutAt = performance.now() + 5000
|
||||
|
||||
const tick = () => {
|
||||
if (changeTracker.isLoadingGraph) {
|
||||
sawLoading = true
|
||||
}
|
||||
|
||||
if (sawLoading && !changeTracker.isLoadingGraph) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (performance.now() > timeoutAt) {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
})
|
||||
|
||||
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
|
||||
await waitForReload
|
||||
}, locale)
|
||||
}
|
||||
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
@@ -447,34 +396,33 @@ This is documentation for a custom node.
|
||||
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
test.describe('Locale-specific documentation', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
|
||||
これは日本語のドキュメントです。
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
|
||||
This is English documentation.
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Set locale to Japanese
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
@@ -483,9 +431,7 @@ This is English documentation.
|
||||
const helpPage = await openSelectionToolboxHelp(comfyPage)
|
||||
await expect(helpPage).toContainText('KSamplerノード')
|
||||
await expect(helpPage).toContainText('これは日本語のドキュメントです')
|
||||
} finally {
|
||||
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Should handle network errors gracefully', async ({ comfyPage }) => {
|
||||
|
||||
@@ -131,6 +131,14 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
'normal',
|
||||
1
|
||||
])
|
||||
|
||||
if (mode.vueNodesEnabled) {
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getWidgetByName('KSampler', 'denoise')
|
||||
.locator('input')
|
||||
).toHaveValue(/^1(?:\.0+)?$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('Success toast is shown after replacement', async ({
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
Asset,
|
||||
AssetCreated,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
|
||||
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
|
||||
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
|
||||
'models/checkpoints/cloud_importable_model.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
@@ -27,13 +40,62 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
}
|
||||
}
|
||||
|
||||
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-existing-cloud-importable-model',
|
||||
name: 'asset-record-display-name.safetensors',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
|
||||
size: 2_048,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
|
||||
function getRequestedIncludeTags(requestUrl: string): string[] {
|
||||
return (
|
||||
new URL(requestUrl).searchParams
|
||||
.get('include_tags')
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function filterAssetsByRequest(
|
||||
assets: ReadonlyArray<Asset>,
|
||||
requestUrl: string
|
||||
): Asset[] {
|
||||
const includeTags = getRequestedIncludeTags(requestUrl)
|
||||
return includeTags.length
|
||||
? assets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: [...assets]
|
||||
}
|
||||
|
||||
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
model_upload_button_enabled: true,
|
||||
private_models_enabled: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Errors tab - Cloud missing models',
|
||||
{ tag: ['@cloud', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableMissingModelImportFeatures(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
@@ -88,5 +150,216 @@ test.describe(
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
|
||||
test('separates importable cloud models from unsupported rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
const importableRows = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelImportableRows
|
||||
)
|
||||
const unsupportedSection = missingModelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelUnsupportedSection
|
||||
)
|
||||
|
||||
await expect(
|
||||
importableRows.getByRole('button', {
|
||||
name: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(unsupportedSection).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Import Not Supported')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(
|
||||
/Nodes that reference the models below do not support imported models/
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByText('Unknown', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByRole('button', {
|
||||
name: 'Load Image',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('opens cloud import with missing-model replacement context', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 1024,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: 'replacement.safetensors',
|
||||
tags: ['loras']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const urlInput = comfyPage.page.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await expect(urlInput).toBeVisible()
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
|
||||
)
|
||||
await comfyPage.page
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
|
||||
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
/Locked to (Checkpoints|checkpoints) for this missing model/
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('uses the synced asset filename when applying an already imported cloud model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let isImportedAssetAvailable = false
|
||||
const visibleAssets = () =>
|
||||
isImportedAssetAvailable
|
||||
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
|
||||
: [LOTUS_DIFFUSION_MODEL]
|
||||
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{ name: 'checkpoints', folders: [] }
|
||||
])
|
||||
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
|
||||
const assets = filterAssetsByRequest(
|
||||
visibleAssets(),
|
||||
route.request().url()
|
||||
)
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
|
||||
const response: AssetMetadata = {
|
||||
content_length: 2048,
|
||||
final_url:
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
|
||||
content_type: 'application/octet-stream',
|
||||
filename: CLOUD_IMPORTABLE_MODEL_NAME,
|
||||
tags: ['checkpoints']
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/assets/download', (route) => {
|
||||
isImportedAssetAvailable = true
|
||||
const response: AssetCreated = {
|
||||
...EXISTING_CLOUD_IMPORTABLE_MODEL,
|
||||
created_new: false
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
})
|
||||
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await missingModelsGroup
|
||||
.getByTestId(TestIds.dialogs.missingModelImport)
|
||||
.click()
|
||||
|
||||
const uploadDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: /Import a model/
|
||||
})
|
||||
const urlInput = uploadDialog.locator(
|
||||
'[data-attr="upload-model-step1-url-input"]'
|
||||
)
|
||||
await urlInput.fill(
|
||||
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
|
||||
)
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step1-continue-button"]')
|
||||
.click()
|
||||
await expect(
|
||||
uploadDialog.getByText(
|
||||
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await uploadDialog
|
||||
.locator('[data-attr="upload-model-step2-confirm-button"]')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
|
||||
?.value
|
||||
})
|
||||
)
|
||||
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -11,6 +12,18 @@ import {
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
).toHaveText(String(count))
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Should display model name and metadata', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
|
||||
await expect(getModelLabel(modelsGroup)).toBeVisible()
|
||||
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
@@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
const expandButton = modelsGroup.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
)
|
||||
await expect(expandButton.first()).toBeVisible()
|
||||
await expectReferenceBadge(modelsGroup, 2)
|
||||
await expandButton.first().click()
|
||||
|
||||
await expect(locateButton.first()).toBeVisible()
|
||||
await expect(
|
||||
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
const copyButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
expect(copiedText).toContain('/api/devtools/')
|
||||
})
|
||||
|
||||
test.describe('OSS-specific', { tag: '@oss' }, () => {
|
||||
@@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -102,6 +115,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelDownload
|
||||
)
|
||||
await expect(downloadButton.first()).toBeVisible()
|
||||
await expect(downloadButton.first()).toHaveText('Download')
|
||||
})
|
||||
|
||||
test('Should render Download all and Refresh actions for one downloadable model', async ({
|
||||
@@ -129,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -137,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -8,6 +9,18 @@ import {
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
const FAKE_MODEL_NAME = 'fake_model.safetensors'
|
||||
|
||||
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
|
||||
return group.getByRole('button', { name: modelName, exact: true })
|
||||
}
|
||||
|
||||
async function expectReferenceBadge(group: Locator, count: number) {
|
||||
await expect(
|
||||
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
|
||||
).toHaveText(String(count))
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
const copyUrlButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Copy URL'
|
||||
})
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
@@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
@@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
@@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
await expect(
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
@@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
await expect(getModelLabel(missingModelGroup)).toBeVisible()
|
||||
|
||||
const promotedModelCombo = comfyPage.vueNodes
|
||||
.getNodeByTitle('Subgraph with Promoted Missing Model')
|
||||
|
||||
@@ -34,6 +34,22 @@ test.describe('Properties panel - Node selection', () => {
|
||||
await expect(panel.contentArea.getByText('seed')).toBeVisible()
|
||||
await expect(panel.contentArea.getByText('steps')).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'a linked widget is disabled',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
const seed = panel.contentArea.getByLabel('seed').locator('input')
|
||||
await comfyPage.searchBoxV2.addNode('Int')
|
||||
const intNode = await comfyPage.vueNodes.getFixtureByTitle(/Int/)
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await ksampler.select()
|
||||
await expect(seed).toBeEnabled()
|
||||
await intNode.getSlot('INT').dragTo(ksampler.getSlot('seed'))
|
||||
await expect(seed).toBeDisabled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Multi-node', () => {
|
||||
|
||||
@@ -15,6 +15,10 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
|
||||
// internal `setup()`, so the page first-loads with mocks already in place.
|
||||
// See cloud-asset-default.spec.ts for the same pattern.
|
||||
//
|
||||
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
|
||||
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
|
||||
// full store, so the per-filter count assertions still cover the behavior.
|
||||
|
||||
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
|
||||
|
||||
@@ -113,7 +117,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
|
||||
@@ -136,7 +140,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -153,7 +157,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('video')
|
||||
@@ -167,7 +171,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('audio')
|
||||
@@ -179,7 +183,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('3d')
|
||||
@@ -193,7 +197,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
@@ -211,7 +215,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets(MIXED_JOBS.length)
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.openFilterMenu()
|
||||
await tab.toggleMediaTypeFilter('image')
|
||||
|
||||
@@ -1105,3 +1105,56 @@ test.describe('Assets sidebar - drag and drop', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]')
|
||||
})
|
||||
})
|
||||
|
||||
test('Insert as node', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory([
|
||||
createMockJob({
|
||||
id: 'job1',
|
||||
preview_output: {
|
||||
filename: `1.png`,
|
||||
type: 'temp',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job2',
|
||||
preview_output: {
|
||||
filename: `2.png`,
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job2',
|
||||
preview_output: {
|
||||
filename: `3.png`,
|
||||
type: 'input',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
const { assetsTab } = comfyPage.menu
|
||||
await assetsTab.open()
|
||||
await assetsTab.waitForAssets()
|
||||
await expect(assetsTab.assetCards).toHaveCount(3)
|
||||
for (const [index, expectedName] of [
|
||||
[0, '1.png [temp]'],
|
||||
[1, '2.png [output]'],
|
||||
[2, '3.png']
|
||||
] as const) {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await assetsTab.assetCards.nth(index).scrollIntoViewIfNeeded()
|
||||
await assetsTab.assetCards.nth(index).click({ button: 'right' })
|
||||
|
||||
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
|
||||
await comfyPage.contextMenu.primeVueMenu.getByText('Insert as node').click()
|
||||
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
const fileWidget = await nodes[0].getWidget(0)
|
||||
await expect.poll(() => fileWidget.getValue()).toBe(expectedName)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, templateApiFixture)
|
||||
const test = mergeTests(createCloudAssetsFixture([]), templateApiFixture)
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkflowTemplates } from '@/platform/workflow/templates/types/temp
|
||||
import { getWav } from '@e2e/fixtures/components/AudioPreview'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
|
||||
|
||||
async function checkTemplateFileExists(
|
||||
page: Page,
|
||||
@@ -505,3 +506,32 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
expect(popup.url()).toEqual(tutorialUrl)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Templates deeplink (new user)',
|
||||
{ tag: ['@slow', '@workflow'] },
|
||||
() => {
|
||||
test('templates dialog never flashes when first-time user opens a template link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templatesFlash = await trackElementFlash(
|
||||
comfyPage.page,
|
||||
TestIds.templates.content
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
|
||||
|
||||
await comfyPage.setup({
|
||||
clearStorage: true,
|
||||
url: '/?template=default'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
expect(await templatesFlash.hasFlashed()).toBe(false)
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -177,6 +177,30 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not drag contents when control is held', async ({ comfyPage }) => {
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const initialNodeBounds = await ksampler.boundingBox()
|
||||
expect(initialNodeBounds).toBeTruthy()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect
|
||||
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
|
||||
.not.toEqual(groupPos)
|
||||
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
|
||||
})
|
||||
|
||||
test('should keep groups aligned after loading legacy Vue workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.14",
|
||||
"version": "1.46.15",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -23,6 +23,7 @@
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check": "oxfmt --check",
|
||||
|
||||
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal file
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
|
||||
import boundingBoxes from '@/locales/en/main.json'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBoxes: boundingBoxes.boundingBoxes,
|
||||
palette: { swatchTitle: 'Edit', addColor: 'Add' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
x: 51,
|
||||
y: 51,
|
||||
width: 256,
|
||||
height: 256,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
|
||||
...over
|
||||
})
|
||||
|
||||
const fakeCtx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function prepCanvas(canvas: HTMLCanvasElement) {
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
canvas.getContext = (() =>
|
||||
fakeCtx) as unknown as HTMLCanvasElement['getContext']
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
canvas.setPointerCapture = () => {}
|
||||
canvas.releasePointerCapture = () => {}
|
||||
}
|
||||
|
||||
function renderWidget(modelValue: BoundingBox[]) {
|
||||
const result = render(WidgetBoundingBoxes, {
|
||||
props: { nodeId: '1', modelValue },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
|
||||
prepCanvas(canvas)
|
||||
return { ...result, canvas }
|
||||
}
|
||||
|
||||
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0] as BoundingBox[]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('WidgetBoundingBoxes', () => {
|
||||
it('renders the canvas and editor shell', () => {
|
||||
renderWidget([])
|
||||
expect(
|
||||
screen.getByTestId('bounding-boxes').querySelector('canvas')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the region editor panel when a region is active', () => {
|
||||
renderWidget([box()])
|
||||
expect(screen.getByText('obj')).toBeTruthy()
|
||||
expect(screen.getByText('text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reveals the text field after switching the region to text', async () => {
|
||||
renderWidget([box()])
|
||||
expect(
|
||||
screen.queryByPlaceholderText('text to render (verbatim)')
|
||||
).toBeNull()
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
expect(
|
||||
screen.getByPlaceholderText('text to render (verbatim)')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears all regions via the clear button', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('Clear all'))
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('draws a region through canvas pointer events', async () => {
|
||||
const { canvas, emitted } = renderWidget([])
|
||||
await fireEvent.pointerDown(canvas, {
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerMove(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerUp(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(lastBoxes(emitted)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks focus and blur on the canvas', async () => {
|
||||
const { canvas } = renderWidget([box()])
|
||||
await fireEvent.focus(canvas)
|
||||
await fireEvent.blur(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens an inline editor on double click', async () => {
|
||||
const { canvas, container } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
expect(container.querySelector('textarea')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('syncs description edits back to the model', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('description of this region'),
|
||||
'a caption'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
|
||||
})
|
||||
|
||||
it('edits the text field once the region is a text region', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('text to render (verbatim)'),
|
||||
'hello'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
|
||||
})
|
||||
|
||||
it('deletes the active region with the Delete key', async () => {
|
||||
const { canvas, emitted } = renderWidget([box()])
|
||||
await fireEvent.keyDown(canvas, { key: 'Delete' })
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('clears hover state on pointer leave', async () => {
|
||||
const { canvas } = renderWidget([
|
||||
box({ x: 10, y: 10, width: 256, height: 256 })
|
||||
])
|
||||
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
|
||||
await fireEvent.pointerLeave(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits the inline editor on blur', async () => {
|
||||
const { canvas, container, emitted } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
const editor = container.querySelector('textarea')!
|
||||
await fireEvent.update(editor, 'committed')
|
||||
await fireEvent.blur(editor)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
})
|
||||
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal file
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands flex size-full flex-col gap-1 select-none"
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeRegion"
|
||||
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'obj'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('obj')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeObj') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'text'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('text')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeText') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeRegion.type === 'text'"
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.textLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.text"
|
||||
:placeholder="$t('boundingBoxes.textPlaceholder')"
|
||||
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.descLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.desc"
|
||||
:placeholder="$t('boundingBoxes.descPlaceholder')"
|
||||
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0 truncate text-sm text-muted-foreground">
|
||||
{{ $t('boundingBoxes.colors') }}
|
||||
</span>
|
||||
<PaletteSwatchRow
|
||||
v-model="activeRegion.palette"
|
||||
:max="maxColors"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: string }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
|
||||
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
|
||||
|
||||
const {
|
||||
canvasStyle,
|
||||
canvasCursor,
|
||||
focused,
|
||||
activeRegion,
|
||||
hasRegions,
|
||||
inlineEditor,
|
||||
maxColors,
|
||||
onPointerDown,
|
||||
onCanvasPointerMove,
|
||||
onDocPointerUp,
|
||||
onPointerLeave,
|
||||
onDoubleClick,
|
||||
onCanvasKeyDown,
|
||||
onInlineKeyDown,
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
})
|
||||
</script>
|
||||
@@ -7,12 +7,26 @@ import { createI18n } from 'vue-i18n'
|
||||
import ErrorOverlay from './ErrorOverlay.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -62,7 +76,6 @@ function createTestI18n() {
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.',
|
||||
viewDetails: 'View details'
|
||||
@@ -108,6 +121,10 @@ function renderOverlay(props: { appMode?: boolean } = {}) {
|
||||
describe('ErrorOverlay', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
mockOpenPanel.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.canvas = null
|
||||
@@ -116,17 +133,12 @@ describe('ErrorOverlay', () => {
|
||||
})
|
||||
|
||||
it('renders a single overlay message without list markup', async () => {
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -137,6 +149,12 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -145,21 +163,19 @@ describe('ErrorOverlay', () => {
|
||||
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
|
||||
'View details'
|
||||
)
|
||||
expect(screen.getByTestId('error-overlay-dismiss')).toHaveAccessibleName(
|
||||
'Close'
|
||||
)
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the app mode button label', async () => {
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -170,6 +186,12 @@ describe('ErrorOverlay', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
renderOverlay({ appMode: true })
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,47 +7,35 @@
|
||||
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="error-overlay"
|
||||
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
class="pointer-events-auto relative flex w-fit max-w-120 min-w-80 flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
<div class="flex w-full items-start gap-2 pr-8">
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3" data-testid="error-overlay-messages">
|
||||
<div
|
||||
class="flex w-full items-start gap-2 pr-8"
|
||||
data-testid="error-overlay-messages"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
data-testid="error-overlay-dismiss"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
>
|
||||
@@ -58,6 +46,17 @@
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="absolute top-2 right-2 size-6 rounded-sm"
|
||||
data-testid="error-overlay-dismiss"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -8,12 +8,26 @@ import { useErrorOverlayState } from './useErrorOverlayState'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
|
||||
const mockErrorGroups = vi.hoisted(() => ({
|
||||
allErrorGroups: { value: [] as ErrorGroup[] },
|
||||
missingPackGroups: { value: [] as MissingPackGroup[] },
|
||||
missingModelGroups: { value: [] as MissingModelGroup[] },
|
||||
missingMediaGroups: { value: [] as MissingMediaGroup[] },
|
||||
swapNodeGroups: { value: [] as SwapNodeGroup[] }
|
||||
}))
|
||||
|
||||
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
|
||||
|
||||
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
|
||||
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
|
||||
useErrorGroups: () => mockErrorGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
|
||||
@@ -44,7 +58,6 @@ function createTestI18n() {
|
||||
messages: {
|
||||
en: {
|
||||
errorOverlay: {
|
||||
errorCount: '{count} ERROR | {count} ERRORS',
|
||||
multipleErrorCount: '{count} error found | {count} errors found',
|
||||
multipleErrorsMessage: 'Resolve them before running the workflow.'
|
||||
}
|
||||
@@ -92,20 +105,19 @@ function mountOverlayState() {
|
||||
describe('useErrorOverlayState', () => {
|
||||
beforeEach(() => {
|
||||
mockAllErrorGroups.value = []
|
||||
mockErrorGroups.missingPackGroups.value = []
|
||||
mockErrorGroups.missingModelGroups.value = []
|
||||
mockErrorGroups.missingMediaGroups.value = []
|
||||
mockErrorGroups.swapNodeGroups.value = []
|
||||
})
|
||||
|
||||
it('uses the raw message for a single uncataloged execution error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -116,6 +128,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Only error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -125,17 +143,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single validation error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Required input is missing',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -152,6 +165,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Required input is missing'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -164,17 +183,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses display copy before raw copy when toast copy is absent', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Friendly validation title',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -190,6 +204,12 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError(['Raw validation error'])
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -202,24 +222,12 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses toast copy for a single runtime error', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Generation failed',
|
||||
count: 1,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -237,6 +245,19 @@ describe('useErrorOverlayState', () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastExecutionError = {
|
||||
prompt_id: 'prompt',
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_message: 'CUDA out of memory',
|
||||
exception_type: 'torch.OutOfMemoryError',
|
||||
traceback: [],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -247,6 +268,44 @@ describe('useErrorOverlayState', () => {
|
||||
})
|
||||
|
||||
it('uses group toast copy for a single missing media error', async () => {
|
||||
mockErrorGroups.missingMediaGroups.value = [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'image.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'image.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
count: 1,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -261,17 +320,6 @@ describe('useErrorOverlayState', () => {
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_media',
|
||||
groupKey: 'missing_media',
|
||||
displayTitle: 'Media input missing',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.',
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
@@ -281,6 +329,147 @@ describe('useErrorOverlayState', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one missing model referenced by multiple nodes', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'missing.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [
|
||||
{ nodeId: '1', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '2', widgetName: 'ckpt_name' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Model missing',
|
||||
toastMessage: 'CheckpointLoaderSimple is missing missing.safetensors.',
|
||||
count: 1,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing Models')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Import a model, or open the node to replace it.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses group copy for one execution group with multiple errors', async () => {
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:required_input_missing',
|
||||
displayTitle: 'Missing connection',
|
||||
displayMessage: 'Required input slots have no connection feeding them.',
|
||||
count: 2,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'KSampler',
|
||||
errors: [
|
||||
{ message: 'KSampler is missing model' },
|
||||
{ message: 'KSampler is missing positive' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Missing connection')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses aggregate copy for one missing model group with multiple rows', async () => {
|
||||
mockErrorGroups.missingModelGroups.value = [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: [
|
||||
{
|
||||
name: 'first.safetensors',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'first.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
|
||||
},
|
||||
{
|
||||
name: 'second.safetensors',
|
||||
representative: {
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'second.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '2', widgetName: 'ckpt_name' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'missing_model',
|
||||
groupKey: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: 'Missing models',
|
||||
toastMessage: '2 model files are missing.',
|
||||
count: 2,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('2 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show when a raw error has no resolved overlay message', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
@@ -295,26 +484,14 @@ describe('useErrorOverlayState', () => {
|
||||
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('uses aggregate copy for multiple errors', async () => {
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
'1': makeNodeError([
|
||||
'First error',
|
||||
'Second error',
|
||||
'Third error',
|
||||
'Fourth error',
|
||||
'Fifth error',
|
||||
'Sixth error',
|
||||
'Seventh error'
|
||||
])
|
||||
}
|
||||
it('uses grouped error counts for aggregate copy', async () => {
|
||||
mockAllErrorGroups.value = [
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:KSampler',
|
||||
displayTitle: 'Execution failed',
|
||||
displayMessage: 'First group message',
|
||||
count: 2,
|
||||
priority: 0,
|
||||
cards: [
|
||||
{
|
||||
@@ -323,13 +500,31 @@ describe('useErrorOverlayState', () => {
|
||||
errors: [{ message: 'First error' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'execution',
|
||||
groupKey: 'execution:CLIPTextEncode',
|
||||
displayTitle: 'Invalid CLIP input',
|
||||
displayMessage: 'Second group message',
|
||||
count: 3,
|
||||
priority: 1,
|
||||
cards: [
|
||||
{
|
||||
id: '2',
|
||||
title: 'CLIPTextEncode',
|
||||
errors: [{ message: 'Second error' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
mountOverlayState()
|
||||
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.showErrorOverlay()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('visible')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('5 errors found')
|
||||
expect(screen.getByTestId('message')).toHaveTextContent(
|
||||
'Resolve them before running the workflow.'
|
||||
)
|
||||
|
||||
@@ -4,11 +4,17 @@ import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type {
|
||||
MissingPackGroup,
|
||||
SwapNodeGroup
|
||||
} from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
|
||||
function resolveSingleOverlayCopy(
|
||||
group: ErrorGroup
|
||||
): { title?: string; message: string } | undefined {
|
||||
type OverlayCopy = { title?: string; message: string }
|
||||
|
||||
function resolveSingleOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
if (group.type === 'execution') {
|
||||
const [card] = group.cards
|
||||
const [error] = card?.errors ?? []
|
||||
@@ -37,27 +43,119 @@ function resolveSingleOverlayCopy(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGroupOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
|
||||
const message =
|
||||
group.displayMessage ?? group.toastMessage ?? group.displayTitle
|
||||
if (!message) return undefined
|
||||
|
||||
return {
|
||||
title: group.displayTitle,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
function countMissingNodeReferences(groups: MissingPackGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function countSwapNodeReferences(groups: SwapNodeGroup[]): number {
|
||||
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
|
||||
}
|
||||
|
||||
function getMissingModelRows(groups: MissingModelGroup[]) {
|
||||
return groups.flatMap((group) => group.models)
|
||||
}
|
||||
|
||||
function getMissingMediaRows(groups: MissingMediaGroup[]) {
|
||||
return groups.flatMap((group) => group.items)
|
||||
}
|
||||
|
||||
function hasSingleRowWithAtMostOneReference(
|
||||
rows: Array<{ referencingNodes: readonly unknown[] }>
|
||||
): boolean {
|
||||
const row = rows[0]
|
||||
return (
|
||||
rows.length === 1 && row !== undefined && row.referencingNodes.length <= 1
|
||||
)
|
||||
}
|
||||
|
||||
interface OverlayGroupContext {
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
}
|
||||
|
||||
function isSingleLeafGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'execution') {
|
||||
return group.cards.length === 1 && group.cards[0]?.errors.length === 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_node') {
|
||||
return (
|
||||
context.missingPackGroups.length === 1 &&
|
||||
countMissingNodeReferences(context.missingPackGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return (
|
||||
context.swapNodeGroups.length === 1 &&
|
||||
countSwapNodeReferences(context.swapNodeGroups) === 1
|
||||
)
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingModelRows(context.missingModelGroups)
|
||||
)
|
||||
}
|
||||
|
||||
return hasSingleRowWithAtMostOneReference(
|
||||
getMissingMediaRows(context.missingMediaGroups)
|
||||
)
|
||||
}
|
||||
|
||||
function shouldUseAggregateCopyForSingleGroup(
|
||||
group: ErrorGroup,
|
||||
context: OverlayGroupContext
|
||||
): boolean {
|
||||
if (group.type === 'missing_node') {
|
||||
return context.missingPackGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'swap_nodes') {
|
||||
return context.swapNodeGroups.length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_model') {
|
||||
return getMissingModelRows(context.missingModelGroups).length > 1
|
||||
}
|
||||
|
||||
if (group.type === 'missing_media') {
|
||||
return getMissingMediaRows(context.missingMediaGroups).length > 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function useErrorOverlayState() {
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { totalErrorCount, isErrorOverlayOpen } =
|
||||
storeToRefs(executionErrorStore)
|
||||
const { allErrorGroups } = useErrorGroups('')
|
||||
const { isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
||||
const {
|
||||
allErrorGroups,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups('')
|
||||
|
||||
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
|
||||
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
|
||||
const singleErrorGroup = computed(() =>
|
||||
hasExactlyOneError.value && allErrorGroups.value.length === 1
|
||||
? allErrorGroups.value[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
const totalErrorCount = computed(() =>
|
||||
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const multipleErrorCountLabel = computed(() =>
|
||||
@@ -68,25 +166,38 @@ export function useErrorOverlayState() {
|
||||
)
|
||||
)
|
||||
|
||||
const singleOverlayCopy = computed(() =>
|
||||
singleErrorGroup.value
|
||||
? resolveSingleOverlayCopy(singleErrorGroup.value)
|
||||
: undefined
|
||||
)
|
||||
const aggregateOverlayCopy = computed<OverlayCopy>(() => ({
|
||||
title: multipleErrorCountLabel.value,
|
||||
message: t('errorOverlay.multipleErrorsMessage')
|
||||
}))
|
||||
|
||||
const overlayMessage = computed(() => {
|
||||
if (hasMultipleErrors.value) {
|
||||
return t('errorOverlay.multipleErrorsMessage')
|
||||
const overlayCopy = computed<OverlayCopy | undefined>(() => {
|
||||
const groups = allErrorGroups.value
|
||||
if (groups.length === 0) return undefined
|
||||
if (groups.length > 1) return aggregateOverlayCopy.value
|
||||
|
||||
const [group] = groups
|
||||
const context = {
|
||||
missingPackGroups: missingPackGroups.value,
|
||||
missingModelGroups: missingModelGroups.value,
|
||||
missingMediaGroups: missingMediaGroups.value,
|
||||
swapNodeGroups: swapNodeGroups.value
|
||||
}
|
||||
|
||||
return singleOverlayCopy.value?.message ?? ''
|
||||
if (shouldUseAggregateCopyForSingleGroup(group, context)) {
|
||||
return aggregateOverlayCopy.value
|
||||
}
|
||||
|
||||
if (isSingleLeafGroup(group, context)) {
|
||||
return resolveSingleOverlayCopy(group) ?? resolveGroupOverlayCopy(group)
|
||||
}
|
||||
|
||||
return resolveGroupOverlayCopy(group)
|
||||
})
|
||||
|
||||
const overlayTitle = computed(() =>
|
||||
hasMultipleErrors.value
|
||||
? multipleErrorCountLabel.value
|
||||
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
|
||||
)
|
||||
const overlayMessage = computed(() => overlayCopy.value?.message ?? '')
|
||||
|
||||
const overlayTitle = computed(() => overlayCopy.value?.title ?? '')
|
||||
|
||||
const isVisible = computed(
|
||||
() =>
|
||||
|
||||
126
src/components/hdr/HdrViewerContent.test.ts
Normal file
126
src/components/hdr/HdrViewerContent.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdrViewerContent from './HdrViewerContent.vue'
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
|
||||
|
||||
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
|
||||
vi.mock('@/composables/useHdrViewer', () => ({
|
||||
useHdrViewer: () => holder.viewer,
|
||||
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { loading: 'Loading', downloadImage: 'Download' },
|
||||
hdrViewer: {
|
||||
failedToLoad: 'Failed',
|
||||
exposure: 'Exposure',
|
||||
normalizeExposure: 'Auto exposure',
|
||||
channel: 'Channel',
|
||||
channels: {
|
||||
rgb: 'RGB',
|
||||
r: 'R',
|
||||
g: 'G',
|
||||
b: 'B',
|
||||
a: 'Alpha',
|
||||
luminance: 'Luminance'
|
||||
},
|
||||
sourceGamut: 'Source gamut',
|
||||
dither: 'Dither',
|
||||
clipWarnings: 'Clip warnings',
|
||||
fitView: 'Fit',
|
||||
histogram: 'Histogram',
|
||||
resolution: 'Resolution',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
mean: 'Mean',
|
||||
stdDev: 'Std dev',
|
||||
nan: 'NaN',
|
||||
inf: 'Inf'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeViewer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
exposureStops: ref(0),
|
||||
dither: ref(true),
|
||||
clipWarnings: ref(false),
|
||||
gamut: ref('sRGB'),
|
||||
channel: ref('r'),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
dimensions: ref('512 x 512'),
|
||||
stats: ref({
|
||||
min: 0,
|
||||
max: 4,
|
||||
mean: 0.5,
|
||||
stdDev: 0.2,
|
||||
nanCount: 2,
|
||||
infCount: 1
|
||||
}),
|
||||
histogram: ref(new Uint32Array([1, 2, 3, 4])),
|
||||
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
|
||||
mount: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
fitView: vi.fn(),
|
||||
normalizeExposure: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
return render(HdrViewerContent, {
|
||||
props: { imageUrl: '/api/view?filename=out.exr' },
|
||||
global: { plugins: [i18n], stubs: { Button: true } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('HdrViewerContent', () => {
|
||||
beforeEach(() => {
|
||||
holder.viewer = makeViewer()
|
||||
})
|
||||
|
||||
it('renders the full statistics set including NaN/Inf', () => {
|
||||
renderViewer()
|
||||
for (const label of [
|
||||
'Resolution',
|
||||
'Min',
|
||||
'Max',
|
||||
'Mean',
|
||||
'Std dev',
|
||||
'NaN',
|
||||
'Inf'
|
||||
]) {
|
||||
screen.getByText(label)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the pixel readout when a pixel is hovered', () => {
|
||||
renderViewer()
|
||||
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('colors the histogram according to the selected channel', () => {
|
||||
holder.viewer = makeViewer({ channel: ref('g') })
|
||||
const { container } = renderViewer()
|
||||
const path = container.querySelector('svg path')
|
||||
expect(path?.getAttribute('class')).toContain('text-green-500')
|
||||
})
|
||||
|
||||
it('renders an option for each channel mode', () => {
|
||||
renderViewer()
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Luminance' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
258
src/components/hdr/HdrViewerContent.vue
Normal file
258
src/components/hdr/HdrViewerContent.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="flex size-full bg-base-background">
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute size-full"
|
||||
data-testid="hdr-viewer-canvas"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="viewer.loading.value"
|
||||
class="absolute inset-0 flex items-center justify-center text-base-foreground"
|
||||
>
|
||||
{{ $t('g.loading') }}...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="viewer.error.value"
|
||||
role="alert"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--image-off] size-12" />
|
||||
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.pixel.value"
|
||||
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
|
||||
data-testid="hdr-pixel-readout"
|
||||
>
|
||||
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
|
||||
<div>
|
||||
{{ formatNum(viewer.pixel.value.r) }}
|
||||
{{ formatNum(viewer.pixel.value.g) }}
|
||||
{{ formatNum(viewer.pixel.value.b) }}
|
||||
<template v-if="viewer.pixel.value.a !== null">
|
||||
{{ formatNum(viewer.pixel.value.a) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
|
||||
<input
|
||||
v-model.number="viewer.exposureStops.value"
|
||||
type="range"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
:aria-label="$t('hdrViewer.exposure')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
@click="viewer.normalizeExposure"
|
||||
>
|
||||
{{ $t('hdrViewer.normalizeExposure') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.channel') }}</label>
|
||||
<select
|
||||
v-model="viewer.channel.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.channel')"
|
||||
>
|
||||
<option v-for="mode in channelModes" :key="mode" :value="mode">
|
||||
{{ channelLabels[mode] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
|
||||
<select
|
||||
v-model="viewer.gamut.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.sourceGamut')"
|
||||
>
|
||||
<option v-for="name in gamutNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-dither"
|
||||
v-model="viewer.dither.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-dither" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.dither') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-clip"
|
||||
v-model="viewer.clipWarnings.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-clip" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.clipWarnings') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="histogramPath" class="space-y-2 p-2">
|
||||
<label>{{ $t('hdrViewer.histogram') }}</label>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
|
||||
>
|
||||
<path
|
||||
:d="histogramPath"
|
||||
:class="histogramColorClass"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.5"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.stats.value"
|
||||
class="space-y-1 p-2 text-xs tabular-nums"
|
||||
>
|
||||
<div v-if="viewer.dimensions.value" class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.resolution') }}</span>
|
||||
<span>{{ viewer.dimensions.value }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.min') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.min) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.max') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.max) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.mean') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.stdDev') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.nanCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.nan') }}</span>
|
||||
<span>{{ viewer.stats.value.nanCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.infCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.inf') }}</span>
|
||||
<span>{{ viewer.stats.value.infCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
|
||||
{{ $t('hdrViewer.fitView') }}
|
||||
</Button>
|
||||
<Button variant="secondary" class="flex-1" @click="handleDownload">
|
||||
{{ $t('g.downloadImage') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ChannelMode } from '@/composables/useHdrViewer'
|
||||
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
|
||||
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
|
||||
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
|
||||
const { imageUrl } = defineProps<{ imageUrl: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const viewer = useHdrViewer()
|
||||
const gamutNames = GAMUT_NAMES
|
||||
const channelModes = CHANNEL_MODES
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const exposureLabel = computed(() => {
|
||||
const value = viewer.exposureStops.value
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
|
||||
)
|
||||
|
||||
const histogramColorClass = computed(() => {
|
||||
switch (viewer.channel.value) {
|
||||
case 'r':
|
||||
return 'text-red-500'
|
||||
case 'g':
|
||||
return 'text-green-500'
|
||||
case 'b':
|
||||
return 'text-blue-500'
|
||||
default:
|
||||
return 'text-base-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
|
||||
rgb: t('hdrViewer.channels.rgb'),
|
||||
r: t('hdrViewer.channels.r'),
|
||||
g: t('hdrViewer.channels.g'),
|
||||
b: t('hdrViewer.channels.b'),
|
||||
a: t('hdrViewer.channels.a'),
|
||||
luminance: t('hdrViewer.channels.luminance')
|
||||
}))
|
||||
|
||||
function formatNum(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
|
||||
? value.toExponential(3)
|
||||
: value.toFixed(4)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadFile(toFullResolutionUrl(imageUrl))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
|
||||
})
|
||||
</script>
|
||||
70
src/components/palette/PaletteSwatchRow.test.ts
Normal file
70
src/components/palette/PaletteSwatchRow.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderRow(modelValue: string[], max = 5) {
|
||||
return render(PaletteSwatchRow, {
|
||||
props: { modelValue, max },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0]
|
||||
}
|
||||
|
||||
describe('PaletteSwatchRow', () => {
|
||||
it('renders one swatch per color', () => {
|
||||
const { container } = renderRow(['#ff0000', '#00ff00'])
|
||||
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
it('removes a color on right click', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
|
||||
expect(lastEmit(emitted)).toEqual(['#00ff00'])
|
||||
})
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
|
||||
button: 0,
|
||||
clientX: 5,
|
||||
clientY: 5
|
||||
})
|
||||
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
48
src/components/palette/PaletteSwatchRow.vue
Normal file
48
src/components/palette/PaletteSwatchRow.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
|
||||
:title="t('palette.addColor')"
|
||||
@click="addColor"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
54
src/components/palette/WidgetColors.test.ts
Normal file
54
src/components/palette/WidgetColors.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetColors from './WidgetColors.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderWidget(modelValue: string[], widget?: { name: string }) {
|
||||
return render(WidgetColors, {
|
||||
props: { modelValue, widget },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.()
|
||||
})
|
||||
|
||||
describe('WidgetColors', () => {
|
||||
it('renders the palette swatch row for each color', () => {
|
||||
renderWidget(['#ff0000', '#00ff00'])
|
||||
const root = screen.getByTestId('colors')
|
||||
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the widget name as an inline label', () => {
|
||||
renderWidget(['#ff0000'], { name: 'color_palette' })
|
||||
expect(screen.getByText('color_palette')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits an updated palette when a color is added', async () => {
|
||||
const { emitted } = renderWidget([])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
const calls = emitted()['update:modelValue'] as unknown[][]
|
||||
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
|
||||
})
|
||||
|
||||
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
|
||||
const { container } = renderWidget(['#ff0000'])
|
||||
const onDocMove = vi.fn()
|
||||
document.addEventListener('pointermove', onDocMove)
|
||||
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
|
||||
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
|
||||
expect(onDocMove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
29
src/components/palette/WidgetColors.vue
Normal file
29
src/components/palette/WidgetColors.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full items-center gap-2"
|
||||
data-testid="colors"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span
|
||||
v-if="widget?.name"
|
||||
class="shrink-0 truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</span>
|
||||
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const MAX_COLORS = 16
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string[]>({ default: () => [] })
|
||||
</script>
|
||||
71
src/components/rightSidePanel/errors/ErrorCardSection.vue
Normal file
71
src/components/rightSidePanel/errors/ErrorCardSection.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,29 +1,31 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -34,7 +36,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -49,7 +51,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -59,29 +61,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -96,13 +98,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -115,60 +117,61 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
'm-0 list-none p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,6 +78,10 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -118,9 +122,6 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -211,7 +212,13 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -326,6 +333,9 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
||||
1
|
||||
)
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('1')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -404,7 +414,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -414,6 +424,40 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -431,7 +475,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -453,7 +497,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -495,6 +539,12 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -503,6 +553,73 @@ describe('TabErrors.vue', () => {
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
|
||||
})
|
||||
|
||||
it('sums the summary hero count across error types', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'Node'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: model',
|
||||
extra_info: { input_name: 'model' }
|
||||
},
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: positive',
|
||||
extra_info: { input_name: 'positive' }
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: clip',
|
||||
extra_info: { input_name: 'clip' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'a.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '4',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'b.png',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
} satisfies { missingMediaCandidates: MissingMediaCandidate[] }
|
||||
})
|
||||
|
||||
// 3 validation items + 2 missing media references
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('5')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
@@ -526,7 +643,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
@@ -539,7 +656,7 @@ describe('TabErrors.vue', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -557,11 +674,8 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-header-refresh')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
|
||||
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,49 +11,62 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="group.count"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -62,7 +75,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -83,7 +96,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
class="shrink-0"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -94,9 +107,10 @@
|
||||
showMissingModelHeaderRefresh
|
||||
"
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click.stop="handleMissingModelRefresh"
|
||||
@@ -112,7 +126,6 @@
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
{{ t('rightSidePanel.missingModels.refresh') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="
|
||||
@@ -129,141 +142,142 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -301,16 +315,14 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
@@ -319,7 +331,6 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -327,6 +338,7 @@ import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { isExecutionItemListGroup } from './executionItemList'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
@@ -347,7 +359,6 @@ const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
@@ -361,37 +372,6 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
group.cards.length > 0 &&
|
||||
group.cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
@@ -423,14 +403,6 @@ function compareExecutionItemListEntry(
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionGroupCount(group: ErrorGroup) {
|
||||
if (group.type !== 'execution') return 0
|
||||
if (isExecutionItemListGroup(group)) {
|
||||
return group.cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
return group.cards.length
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
@@ -463,20 +435,17 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
const missingModelDownloadableModels = computed(() => {
|
||||
if (isCloud) return []
|
||||
|
||||
return getDownloadableModels(missingModelGroups.value)
|
||||
})
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() =>
|
||||
!isCloud &&
|
||||
missingModelGroups.value.length > 0 &&
|
||||
missingModelDownloadableModels.value.length === 0
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
function handleMissingModelRefresh() {
|
||||
if (missingModelStore.isRefreshingMissingModels) return
|
||||
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
|
||||
21
src/components/rightSidePanel/errors/executionItemList.ts
Normal file
21
src/components/rightSidePanel/errors/executionItemList.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
|
||||
export function shouldRenderExecutionItemList(cards: ErrorCardData[]): boolean {
|
||||
return (
|
||||
cards.length > 0 &&
|
||||
cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function isExecutionItemListGroup(group: ErrorGroup): boolean {
|
||||
return (
|
||||
group.type === 'execution' && shouldRenderExecutionItemList(group.cards)
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface ErrorGroupBase extends Omit<ResolvedErrorMessage, 'displayTitle'> {
|
||||
groupKey: string
|
||||
/** Human-friendly title resolved for UI display. */
|
||||
displayTitle: string
|
||||
count: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
@@ -793,53 +793,6 @@ describe('useErrorGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupedErrorMessages', () => {
|
||||
it('returns empty array when no errors', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.groupedErrorMessages.value).toEqual([])
|
||||
})
|
||||
|
||||
it('collects unique display messages from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{ type: 'err_a', message: 'Error A', details: '' },
|
||||
{ type: 'err_b', message: 'Error B', details: '' }
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const messages = groups.groupedErrorMessages.value
|
||||
expect(messages).toEqual([unknownValidationMessage])
|
||||
})
|
||||
|
||||
it('includes missing node group display message', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(groups.groupedErrorMessages.value).toContain(
|
||||
missingGroup!.displayMessage
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missingModelGroups', () => {
|
||||
it('returns empty array when no missing models', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
@@ -982,7 +935,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
expect(modelGroup?.groupKey).toBe('missing_model')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1098,7 +1051,7 @@ describe('useErrorGroups', () => {
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -25,14 +25,15 @@ import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import { shouldRenderExecutionItemList } from './executionItemList'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import {
|
||||
countMissingModels,
|
||||
groupMissingModelCandidates
|
||||
} from '@/platform/missingModel/missingModelGrouping'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import {
|
||||
@@ -49,9 +50,6 @@ const PROMPT_CARD_ID = '__prompt__'
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
/** Sentinel key for grouping non-asset-supported missing models. */
|
||||
const UNSUPPORTED = Symbol('unsupported')
|
||||
|
||||
export interface MissingPackGroup {
|
||||
packId: string | null
|
||||
nodeTypes: MissingNodeType[]
|
||||
@@ -152,16 +150,28 @@ function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
||||
return compareExecutionId(a.nodeId, b.nodeId)
|
||||
}
|
||||
|
||||
function countExecutionCards(cards: ErrorCardData[]): number {
|
||||
if (shouldRenderExecutionItemList(cards)) {
|
||||
return cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
|
||||
return cards.length
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([rawGroupKey, groupData]) => ({
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.map(([rawGroupKey, groupData]) => {
|
||||
const cards = Array.from(groupData.cards.values()).sort(compareNodeId)
|
||||
return {
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
count: countExecutionCards(cards),
|
||||
cards,
|
||||
priority: groupData.priority
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority
|
||||
return a.displayTitle.localeCompare(b.displayTitle)
|
||||
@@ -220,11 +230,13 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
return groups
|
||||
.map((group, gi) => {
|
||||
if (group.type !== 'execution') return group
|
||||
const cards = group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||
matchedCardKeys.has(`${gi}:${ci}`)
|
||||
)
|
||||
return {
|
||||
...group,
|
||||
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||
matchedCardKeys.has(`${gi}:${ci}`)
|
||||
)
|
||||
cards,
|
||||
count: countExecutionCards(cards)
|
||||
}
|
||||
})
|
||||
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
|
||||
@@ -591,6 +603,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groups.push({
|
||||
type: 'swap_nodes' as const,
|
||||
groupKey: 'swap_nodes',
|
||||
count: swapNodeGroups.value.length,
|
||||
priority: 0,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
@@ -605,6 +618,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groups.push({
|
||||
type: 'missing_node' as const,
|
||||
groupKey: 'missing_node',
|
||||
count: missingPackGroups.value.length,
|
||||
priority: 1,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
@@ -618,60 +632,21 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
|
||||
* Within each group, candidates with the same model name are merged into a single view model. */
|
||||
const missingModelGroups = computed<MissingModelGroup[]>(() => {
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
|
||||
type GroupKey = string | null | typeof UNSUPPORTED
|
||||
const map = new Map<
|
||||
GroupKey,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
|
||||
for (const c of candidates) {
|
||||
const groupKey: GroupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
// All candidates in the same directory share the same isAssetSupported
|
||||
// value in practice (a directory is either asset-supported or not).
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
return groupMissingModelCandidates(
|
||||
missingModelStore.missingModelCandidates,
|
||||
isCloud
|
||||
)
|
||||
})
|
||||
|
||||
function buildMissingModelGroups(): ErrorGroup[] {
|
||||
if (!missingModelGroups.value.length) return []
|
||||
const count = missingModelGroups.value.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
const count = countMissingModels(missingModelGroups.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
groupKey: 'missing_model',
|
||||
count,
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
@@ -696,6 +671,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
groupKey: 'missing_media',
|
||||
count: totalRows,
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
@@ -737,37 +713,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
|
||||
const map = new Map<
|
||||
string | null | typeof UNSUPPORTED,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
for (const c of filtered) {
|
||||
const groupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
})
|
||||
|
||||
const filteredMissingMediaGroups = computed(() => {
|
||||
@@ -783,14 +729,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingModelGroups.value.length) return []
|
||||
const count = filteredMissingModelGroups.value.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
const count = countMissingModels(filteredMissingModelGroups.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
groupKey: 'missing_model',
|
||||
count,
|
||||
priority: 2,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
@@ -811,6 +755,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
groupKey: 'missing_media',
|
||||
count: totalRows,
|
||||
priority: 3,
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
@@ -865,22 +810,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
if (group.type === 'execution') {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.displayMessage ?? err.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages.add(group.displayMessage ?? group.displayTitle)
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
@@ -889,7 +818,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -72,6 +73,15 @@ function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
return source ?? { node, widget }
|
||||
}
|
||||
|
||||
const isLinked = computed(() => {
|
||||
const safeWidget = useVueNodeLifecycle()
|
||||
.nodeManager.value?.vueNodeData.get(String(node.id))
|
||||
?.widgets?.find((w) => w.name === widget.name)
|
||||
return safeWidget?.slotMetadata
|
||||
? !!safeWidget.slotMetadata.linked
|
||||
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
|
||||
})
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
@@ -80,12 +90,14 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
|
||||
const baseOptions = widgetState?.options ?? widget.options
|
||||
const disabled = isLinked.value || !!widget.disabled || undefined
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widgetState?.value ?? widget.value,
|
||||
label: widgetState?.label ?? widget.label,
|
||||
options: widgetState?.options ?? widget.options,
|
||||
options: { ...baseOptions, disabled },
|
||||
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
|
||||
controlWidget: getControlWidget(sourceWidget)
|
||||
}
|
||||
|
||||
237
src/composables/boundingBoxes/boundingBoxesUtil.test.ts
Normal file
237
src/composables/boundingBoxes/boundingBoxesUtil.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
import type { HitMode, Region } from './boundingBoxesUtil'
|
||||
import {
|
||||
applyDrag,
|
||||
boxesAt,
|
||||
fromBoundingBoxes,
|
||||
tagRects,
|
||||
toBoundingBoxes
|
||||
} from './boundingBoxesUtil'
|
||||
|
||||
const region = (over: Partial<Region> = {}): Region => ({
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
w: 0.2,
|
||||
h: 0.2,
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: [],
|
||||
...over
|
||||
})
|
||||
|
||||
describe('applyDrag', () => {
|
||||
it('moves without resizing and keeps width/height', () => {
|
||||
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
|
||||
expect(out.x).toBeCloseTo(0.3)
|
||||
expect(out.y).toBeCloseTo(0.3)
|
||||
expect(out.w).toBeCloseTo(0.2)
|
||||
expect(out.h).toBeCloseTo(0.2)
|
||||
})
|
||||
|
||||
it('clamps a move so the box stays inside the unit square', () => {
|
||||
const out = applyDrag(
|
||||
'move',
|
||||
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
|
||||
0.5,
|
||||
0.5
|
||||
)
|
||||
expect(out.x).toBeCloseTo(0.8)
|
||||
expect(out.y).toBeCloseTo(0.8)
|
||||
})
|
||||
|
||||
it('grows from the bottom-right for draw and resize-br', () => {
|
||||
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
|
||||
const out = applyDrag(
|
||||
mode,
|
||||
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
|
||||
0.1,
|
||||
0.2
|
||||
)
|
||||
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
|
||||
expect(out.w).toBeCloseTo(0.2)
|
||||
expect(out.h).toBeCloseTo(0.3)
|
||||
}
|
||||
})
|
||||
|
||||
it('moves the top-left corner on resize-tl', () => {
|
||||
const out = applyDrag(
|
||||
'resize-tl',
|
||||
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
|
||||
0.1,
|
||||
0.1
|
||||
)
|
||||
expect(out.x).toBeCloseTo(0.6)
|
||||
expect(out.y).toBeCloseTo(0.6)
|
||||
expect(out.w).toBeCloseTo(0.1)
|
||||
expect(out.h).toBeCloseTo(0.1)
|
||||
})
|
||||
|
||||
it('normalizes a corner drag that inverts the box', () => {
|
||||
const out = applyDrag(
|
||||
'resize-tl',
|
||||
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
|
||||
0.3,
|
||||
0
|
||||
)
|
||||
expect(out.x).toBeCloseTo(0.7)
|
||||
expect(out.w).toBeCloseTo(0.1)
|
||||
expect(out.y).toBeCloseTo(0.5)
|
||||
expect(out.h).toBeCloseTo(0.2)
|
||||
})
|
||||
|
||||
it('resizes single edges', () => {
|
||||
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
|
||||
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
|
||||
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
|
||||
expect(top.y).toBeCloseTo(0.5)
|
||||
expect(top.h).toBeCloseTo(0.1)
|
||||
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
|
||||
expect(left.x).toBeCloseTo(0.5)
|
||||
expect(left.w).toBeCloseTo(0.1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boxesAt', () => {
|
||||
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
|
||||
|
||||
it('detects a corner handle', () => {
|
||||
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
|
||||
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
|
||||
})
|
||||
|
||||
it('detects an interior move', () => {
|
||||
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
|
||||
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
|
||||
})
|
||||
|
||||
it('returns nothing when the pointer misses every box', () => {
|
||||
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
|
||||
})
|
||||
|
||||
it('brings the active box to the front of overlapping candidates', () => {
|
||||
const overlapping: Region[] = [
|
||||
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
|
||||
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
|
||||
]
|
||||
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
|
||||
expect(hits).toHaveLength(2)
|
||||
expect(hits[0].index).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagRects', () => {
|
||||
const measure = (s: string) => s.length * 7
|
||||
|
||||
it('places the first tag at the top-left corner', () => {
|
||||
const rects = tagRects(
|
||||
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
|
||||
100,
|
||||
100,
|
||||
measure
|
||||
)
|
||||
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
|
||||
expect(rects[0].w).toBe(measure('01') + 8)
|
||||
})
|
||||
|
||||
it('moves a colliding tag to a different corner', () => {
|
||||
const boxes = [
|
||||
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
|
||||
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
|
||||
]
|
||||
const rects = tagRects(boxes, 100, 100, measure)
|
||||
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
|
||||
expect(sameSpot).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromBoundingBoxes', () => {
|
||||
it('converts pixel boxes to normalized regions with metadata', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
w: 0.3,
|
||||
h: 0.4,
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
desc: 'd',
|
||||
palette: ['#fff']
|
||||
})
|
||||
})
|
||||
|
||||
it('fills defaults when metadata is missing or partial', () => {
|
||||
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: []
|
||||
})
|
||||
})
|
||||
|
||||
it('drops entries that are not bounding boxes', () => {
|
||||
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('guards against zero dimensions', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 5,
|
||||
height: 5,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: [] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
|
||||
x: 5,
|
||||
y: 5,
|
||||
w: 5,
|
||||
h: 5
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toBoundingBoxes', () => {
|
||||
it('rounds normalized regions back to pixels and copies the palette', () => {
|
||||
const palette = ['#abc']
|
||||
const regions: Region[] = [
|
||||
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
|
||||
]
|
||||
const [box] = toBoundingBoxes(regions, 1000, 1000)
|
||||
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
|
||||
expect(box.metadata.palette).toEqual(['#abc'])
|
||||
expect(box.metadata.palette).not.toBe(palette)
|
||||
})
|
||||
|
||||
it('round-trips from pixels to regions and back', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: [] }
|
||||
}
|
||||
]
|
||||
const restored = toBoundingBoxes(
|
||||
fromBoundingBoxes(boxes, 1000, 1000),
|
||||
1000,
|
||||
1000
|
||||
)
|
||||
expect(restored).toEqual(boxes)
|
||||
})
|
||||
})
|
||||
246
src/composables/boundingBoxes/boundingBoxesUtil.ts
Normal file
246
src/composables/boundingBoxes/boundingBoxesUtil.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
|
||||
|
||||
export type HitMode =
|
||||
| 'move'
|
||||
| 'draw'
|
||||
| 'resize-tl'
|
||||
| 'resize-tr'
|
||||
| 'resize-bl'
|
||||
| 'resize-br'
|
||||
| 'resize-t'
|
||||
| 'resize-b'
|
||||
| 'resize-l'
|
||||
| 'resize-r'
|
||||
|
||||
export interface Region extends BoundingBoxMetadata {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
interface BoxCandidate {
|
||||
index: number
|
||||
mode: HitMode
|
||||
}
|
||||
|
||||
interface TagRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
tag: string
|
||||
}
|
||||
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||
|
||||
function normalizeBox(b: Region): Region {
|
||||
let { x, y, w, h } = b
|
||||
if (w < 0) {
|
||||
x += w
|
||||
w = -w
|
||||
}
|
||||
if (h < 0) {
|
||||
y += h
|
||||
h = -h
|
||||
}
|
||||
x = clamp01(x)
|
||||
y = clamp01(y)
|
||||
w = Math.min(w, 1 - x)
|
||||
h = Math.min(h, 1 - y)
|
||||
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
|
||||
}
|
||||
|
||||
function rectHitTest(
|
||||
mx: number,
|
||||
my: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
rx: number,
|
||||
ry: number
|
||||
): HitMode | null {
|
||||
const h = (cx: number, cy: number) =>
|
||||
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
|
||||
if (h(x1, y1)) return 'resize-tl'
|
||||
if (h(x2, y1)) return 'resize-tr'
|
||||
if (h(x1, y2)) return 'resize-bl'
|
||||
if (h(x2, y2)) return 'resize-br'
|
||||
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
|
||||
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
|
||||
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
|
||||
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
|
||||
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
|
||||
return null
|
||||
}
|
||||
|
||||
export function applyDrag(
|
||||
mode: HitMode,
|
||||
start: Region,
|
||||
dx: number,
|
||||
dy: number
|
||||
): Region {
|
||||
let { x, y, w, h } = start
|
||||
switch (mode) {
|
||||
case 'move':
|
||||
x += dx
|
||||
y += dy
|
||||
x = clamp01(Math.min(x, 1 - w))
|
||||
y = clamp01(Math.min(y, 1 - h))
|
||||
break
|
||||
case 'draw':
|
||||
case 'resize-br':
|
||||
w += dx
|
||||
h += dy
|
||||
break
|
||||
case 'resize-tl':
|
||||
x += dx
|
||||
y += dy
|
||||
w -= dx
|
||||
h -= dy
|
||||
break
|
||||
case 'resize-tr':
|
||||
y += dy
|
||||
w += dx
|
||||
h -= dy
|
||||
break
|
||||
case 'resize-bl':
|
||||
x += dx
|
||||
w -= dx
|
||||
h += dy
|
||||
break
|
||||
case 'resize-t':
|
||||
y += dy
|
||||
h -= dy
|
||||
break
|
||||
case 'resize-b':
|
||||
h += dy
|
||||
break
|
||||
case 'resize-l':
|
||||
x += dx
|
||||
w -= dx
|
||||
break
|
||||
case 'resize-r':
|
||||
w += dx
|
||||
break
|
||||
}
|
||||
return mode === 'move'
|
||||
? { ...start, x, y }
|
||||
: normalizeBox({ ...start, x, y, w, h })
|
||||
}
|
||||
|
||||
export function boxesAt(
|
||||
regions: readonly Region[],
|
||||
mxN: number,
|
||||
myN: number,
|
||||
handlePx: number,
|
||||
logW: number,
|
||||
logH: number,
|
||||
activeIdx: number
|
||||
): BoxCandidate[] {
|
||||
const rx = handlePx / Math.max(1, logW)
|
||||
const ry = handlePx / Math.max(1, logH)
|
||||
const res: BoxCandidate[] = []
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
const b = regions[i]
|
||||
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
|
||||
if (mode) res.push({ index: i, mode })
|
||||
}
|
||||
const ai = res.findIndex((c) => c.index === activeIdx)
|
||||
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
|
||||
return res
|
||||
}
|
||||
|
||||
export function tagRects(
|
||||
regions: readonly Region[],
|
||||
logW: number,
|
||||
logH: number,
|
||||
measureWidth: (s: string) => number,
|
||||
height = 14
|
||||
): TagRect[] {
|
||||
const placed: TagRect[] = []
|
||||
const rects: TagRect[] = []
|
||||
const hits = (a: TagRect, b: TagRect) =>
|
||||
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
const b = regions[i]
|
||||
const x1 = b.x * logW
|
||||
const y1 = b.y * logH
|
||||
const x2 = (b.x + b.w) * logW
|
||||
const y2 = (b.y + b.h) * logH
|
||||
const tag = String(i + 1).padStart(2, '0')
|
||||
const w = measureWidth(tag) + 8
|
||||
let pick: [number, number] = [x1, y1]
|
||||
for (const [cx, cy] of [
|
||||
[x1, y1],
|
||||
[x2 - w, y1],
|
||||
[x2 - w, y2 - height],
|
||||
[x1, y2 - height]
|
||||
] as const) {
|
||||
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
|
||||
if (!placed.some((p) => hits(candidate, p))) {
|
||||
pick = [cx, cy]
|
||||
break
|
||||
}
|
||||
}
|
||||
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
|
||||
placed.push(r)
|
||||
rects[i] = r
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
function isBoundingBox(b: unknown): b is BoundingBox {
|
||||
if (!b || typeof b !== 'object') return false
|
||||
const box = b as Record<string, unknown>
|
||||
return (
|
||||
typeof box.x === 'number' &&
|
||||
typeof box.y === 'number' &&
|
||||
typeof box.width === 'number' &&
|
||||
typeof box.height === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function fromBoundingBoxes(
|
||||
boxes: readonly BoundingBox[],
|
||||
width: number,
|
||||
height: number
|
||||
): Region[] {
|
||||
const w = width || 1
|
||||
const h = height || 1
|
||||
return boxes.filter(isBoundingBox).map((box) => {
|
||||
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
|
||||
return {
|
||||
x: box.x / w,
|
||||
y: box.y / h,
|
||||
w: box.width / w,
|
||||
h: box.height / h,
|
||||
type: meta.type === 'text' ? 'text' : 'obj',
|
||||
text: typeof meta.text === 'string' ? meta.text : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : '',
|
||||
palette: Array.isArray(meta.palette)
|
||||
? meta.palette.filter((c): c is string => typeof c === 'string')
|
||||
: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function toBoundingBoxes(
|
||||
regions: readonly Region[],
|
||||
width: number,
|
||||
height: number
|
||||
): BoundingBox[] {
|
||||
return regions.map((r) => ({
|
||||
x: Math.round(r.x * width),
|
||||
y: Math.round(r.y * height),
|
||||
width: Math.round(r.w * width),
|
||||
height: Math.round(r.h * height),
|
||||
metadata: {
|
||||
type: r.type,
|
||||
text: r.text,
|
||||
desc: r.desc,
|
||||
palette: r.palette.slice()
|
||||
}
|
||||
}))
|
||||
}
|
||||
249
src/composables/boundingBoxes/useBoundingBoxes.test.ts
Normal file
249
src/composables/boundingBoxes/useBoundingBoxes.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function makeCanvas(): HTMLCanvasElement {
|
||||
const el = document.createElement('canvas')
|
||||
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
|
||||
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
el.focus = () => {}
|
||||
el.setPointerCapture = () => {}
|
||||
el.releasePointerCapture = () => {}
|
||||
return el
|
||||
}
|
||||
|
||||
function makeNode() {
|
||||
return {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
}
|
||||
|
||||
const pe = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
over: Partial<PointerEvent> = {}
|
||||
) =>
|
||||
({
|
||||
button: 0,
|
||||
clientX,
|
||||
clientY,
|
||||
altKey: false,
|
||||
pointerId: 1,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
...over
|
||||
}) as unknown as PointerEvent
|
||||
|
||||
const flush = async () => {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
type Api = ReturnType<typeof useBoundingBoxes>
|
||||
interface Captured extends Api {
|
||||
canvasEl: ShallowRef<HTMLCanvasElement | null>
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
|
||||
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
|
||||
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
|
||||
const modelValue = ref(initial)
|
||||
const api = useBoundingBoxes('1', {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
})
|
||||
captured = { canvasEl, modelValue, ...api }
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
render(Harness)
|
||||
captured!.canvasEl.value = makeCanvas()
|
||||
return captured!
|
||||
}
|
||||
|
||||
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
x: 51,
|
||||
y: 51,
|
||||
width: 256,
|
||||
height: 256,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
|
||||
...over
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = makeNode()
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
void Promise.resolve().then(() => cb(0))
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes initialization', () => {
|
||||
it('derives regions from the initial model value', () => {
|
||||
const c = setup([box()])
|
||||
expect(c.hasRegions.value).toBe(true)
|
||||
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
|
||||
})
|
||||
|
||||
it('exposes an aspect-ratio canvas style from the node width/height', () => {
|
||||
const c = setup()
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
|
||||
})
|
||||
|
||||
it('starts with no active region when empty', () => {
|
||||
const c = setup()
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.activeRegion.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes drawing', () => {
|
||||
it('draws a new region and syncs it to the model value', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('discards a zero-size draw', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onDocPointerUp(pe(10, 10))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects an existing region instead of drawing when clicking inside it', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes region editing', () => {
|
||||
it('changes the active region type', async () => {
|
||||
const c = setup([box()])
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.type).toBe('text')
|
||||
})
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears all regions', async () => {
|
||||
const c = setup([box(), box({ x: 0 })])
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes inline editor', () => {
|
||||
it('opens on double click and commits the description', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
|
||||
c.inlineEditor.value!.value = 'a label'
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the inline editor on Escape', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
it('switches to a pointer cursor over a tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
})
|
||||
614
src/composables/boundingBoxes/useBoundingBoxes.ts
Normal file
614
src/composables/boundingBoxes/useBoundingBoxes.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
applyDrag,
|
||||
boxesAt,
|
||||
fromBoundingBoxes,
|
||||
tagRects,
|
||||
toBoundingBoxes
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import type {
|
||||
HitMode,
|
||||
Region
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
|
||||
|
||||
const HANDLE_PX = 8
|
||||
const DIMENSION_STEP = 16
|
||||
const BG_DIM = 0.75
|
||||
const MAX_ELEMENT_COLORS = 5
|
||||
|
||||
interface InlineEditorState {
|
||||
value: string
|
||||
style: Record<string, string>
|
||||
index: number
|
||||
}
|
||||
|
||||
interface UseBoundingBoxesOptions {
|
||||
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
|
||||
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
export function useBoundingBoxes(
|
||||
nodeId: string,
|
||||
{
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
}: UseBoundingBoxesOptions
|
||||
) {
|
||||
const focused = ref(false)
|
||||
const drawing = ref(false)
|
||||
const dragMode = ref<HitMode | null>(null)
|
||||
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
|
||||
const boxAtStart = ref<Region | null>(null)
|
||||
const hoverIndex = ref<number | null>(null)
|
||||
const hoverTagIndex = ref<number | null>(null)
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
const inlineEditor = ref<InlineEditorState | null>(null)
|
||||
|
||||
const { width: containerWidth } = useElementSize(canvasContainer)
|
||||
|
||||
const litegraphNode = computed(() =>
|
||||
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
|
||||
)
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isNodeSelected = computed(() =>
|
||||
selectedNodeIds.value.has(String(nodeId))
|
||||
)
|
||||
|
||||
function dimWidget(name: 'width' | 'height'): number | undefined {
|
||||
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
|
||||
return typeof v === 'number' && v > 0 ? v : undefined
|
||||
}
|
||||
const widthValue = computed(() => dimWidget('width') ?? 1024)
|
||||
const heightValue = computed(() => dimWidget('height') ?? 1024)
|
||||
|
||||
const state = ref({
|
||||
regions: fromBoundingBoxes(
|
||||
modelValue.value ?? [],
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
})
|
||||
const activeIndex = ref(state.value.regions.length ? 0 : -1)
|
||||
|
||||
const aspectRatio = computed(
|
||||
() => `${widthValue.value} / ${heightValue.value}`
|
||||
)
|
||||
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
|
||||
|
||||
const activeRegion = computed(() =>
|
||||
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
|
||||
)
|
||||
const hasRegions = computed(() => state.value.regions.length > 0)
|
||||
|
||||
function clampToCanvas(n: number) {
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function logicalSize() {
|
||||
const el = canvasEl.value
|
||||
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
|
||||
}
|
||||
|
||||
function pointerNorm(e: PointerEvent) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return { x: 0, y: 0 }
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
x: clampToCanvas((e.clientX - r.left) / r.width),
|
||||
y: clampToCanvas((e.clientY - r.top) / r.height)
|
||||
}
|
||||
}
|
||||
|
||||
let rafHandle = 0
|
||||
function requestDraw() {
|
||||
if (rafHandle) return
|
||||
rafHandle = requestAnimationFrame(() => {
|
||||
rafHandle = 0
|
||||
drawCanvas()
|
||||
})
|
||||
}
|
||||
|
||||
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
|
||||
return ctx.measureText(s).width
|
||||
}
|
||||
|
||||
function drawCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const bw = Math.max(1, Math.round(W * dpr))
|
||||
const bh = Math.max(1, Math.round(H * dpr))
|
||||
if (el.width !== bw || el.height !== bh) {
|
||||
el.width = bw
|
||||
el.height = bh
|
||||
}
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
if (bgImage.value) {
|
||||
ctx.drawImage(bgImage.value, 0, 0, W, H)
|
||||
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
const showActive = focused.value || isNodeSelected.value
|
||||
const aIdx = showActive ? activeIndex.value : -1
|
||||
const order = state.value.regions
|
||||
.map((_, i) => i)
|
||||
.filter((i) => i !== aIdx)
|
||||
.reverse()
|
||||
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
|
||||
|
||||
ctx.font = 'bold 11px monospace'
|
||||
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
|
||||
measureWidth(ctx, s)
|
||||
)
|
||||
|
||||
for (const i of order) {
|
||||
const b = state.value.regions[i]
|
||||
const active = i === aIdx
|
||||
const pal = (b.palette || []).filter(Boolean)
|
||||
const col = pal.length ? pal[0] : '#8c8c8c'
|
||||
const x1 = b.x * W
|
||||
const y1 = b.y * H
|
||||
const x2 = (b.x + b.w) * W
|
||||
const y2 = (b.y + b.h) * H
|
||||
const w = x2 - x1
|
||||
const h = y2 - y1
|
||||
const hovered = i === hoverIndex.value || active
|
||||
|
||||
if (active) {
|
||||
ctx.fillStyle = 'rgba(26,26,26,0.88)'
|
||||
ctx.fillRect(x1, y1, w, h)
|
||||
}
|
||||
ctx.fillStyle = col + (hovered ? '3a' : '22')
|
||||
ctx.fillRect(x1, y1, w, h)
|
||||
|
||||
const lw = active ? 2 : hovered ? 1.5 : 1
|
||||
ctx.strokeStyle = col
|
||||
ctx.lineWidth = lw
|
||||
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
|
||||
|
||||
if (pal.length) {
|
||||
const sw = w / pal.length
|
||||
const sh = 7
|
||||
for (let p = 0; p < pal.length; p++) {
|
||||
const sx = x1 + Math.round(p * sw)
|
||||
ctx.fillStyle = pal[p]
|
||||
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(x1, y1, w, h)
|
||||
ctx.clip()
|
||||
|
||||
let body = b.desc || ''
|
||||
if (b.type === 'text' && b.text)
|
||||
body = `"${b.text}"` + (body ? ` — ${body}` : '')
|
||||
if (body) {
|
||||
ctx.font = '12px monospace'
|
||||
ctx.fillStyle = readableTextColor(col)
|
||||
const pad = 4
|
||||
const lh = 14
|
||||
let ty = y1 + 15 + 12
|
||||
for (const line of wrapLines(ctx, body, w - pad * 2)) {
|
||||
if (ty > y1 + h) break
|
||||
ctx.fillText(line, x1 + pad, ty)
|
||||
ty += lh
|
||||
}
|
||||
}
|
||||
|
||||
const tr = tag_rects[i]
|
||||
ctx.font = 'bold 11px monospace'
|
||||
ctx.fillStyle = col
|
||||
ctx.fillRect(tr.x, tr.y, tr.w, 14)
|
||||
if (i === hoverTagIndex.value) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.25)'
|
||||
ctx.fillRect(tr.x, tr.y, tr.w, 14)
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
|
||||
}
|
||||
ctx.fillStyle = textOnColor(col)
|
||||
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function wrapLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxW: number
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
for (const para of text.split('\n')) {
|
||||
let line = ''
|
||||
for (const word of para.split(/\s+/)) {
|
||||
if (!word) continue
|
||||
const test = line ? `${line} ${word}` : word
|
||||
if (line && ctx.measureText(test).width > maxW) {
|
||||
out.push(line)
|
||||
line = word
|
||||
} else {
|
||||
line = test
|
||||
}
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const hitTestPoint = (mN: { x: number; y: number }) => {
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const cands = boxesAt(
|
||||
state.value.regions,
|
||||
mN.x,
|
||||
mN.y,
|
||||
HANDLE_PX,
|
||||
W,
|
||||
H,
|
||||
activeIndex.value
|
||||
)
|
||||
if (!cands.length) return null
|
||||
return (
|
||||
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
|
||||
cands[0]
|
||||
)
|
||||
}
|
||||
|
||||
const titleAt = (mN: { x: number; y: number }) => {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const rects = tagRects(state.value.regions, W, H, (s) =>
|
||||
measureWidth(ctx, s)
|
||||
)
|
||||
const px = mN.x * W
|
||||
const py = mN.y * H
|
||||
for (let i = state.value.regions.length - 1; i >= 0; i--) {
|
||||
const r = rects[i]
|
||||
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
|
||||
return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const cands = boxesAt(
|
||||
state.value.regions,
|
||||
mN.x,
|
||||
mN.y,
|
||||
HANDLE_PX,
|
||||
W,
|
||||
H,
|
||||
activeIndex.value
|
||||
)
|
||||
if (!cands.length) return null
|
||||
const activeResize = cands.find(
|
||||
(c) => c.index === activeIndex.value && c.mode !== 'move'
|
||||
)
|
||||
if (activeResize && !cycle) return activeResize
|
||||
const ti = titleAt(mN)
|
||||
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
|
||||
if (cycle && cands.length > 1) {
|
||||
const pos = cands.findIndex((c) => c.index === activeIndex.value)
|
||||
return cands[(pos + 1) % cands.length]
|
||||
}
|
||||
return (
|
||||
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
|
||||
cands[0]
|
||||
)
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
canvasEl.value?.focus()
|
||||
hoverTagIndex.value = null
|
||||
hoverIndex.value = null
|
||||
const mN = pointerNorm(e)
|
||||
const hit = pickForSelection(mN, e.altKey)
|
||||
if (hit) {
|
||||
activeIndex.value = hit.index
|
||||
dragMode.value = hit.mode
|
||||
boxAtStart.value = { ...state.value.regions[hit.index] }
|
||||
} else {
|
||||
dragMode.value = 'draw'
|
||||
const nb: Region = {
|
||||
x: mN.x,
|
||||
y: mN.y,
|
||||
w: 0,
|
||||
h: 0,
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: []
|
||||
}
|
||||
state.value.regions.push(nb)
|
||||
activeIndex.value = state.value.regions.length - 1
|
||||
boxAtStart.value = { ...nb }
|
||||
}
|
||||
drawing.value = true
|
||||
dragStartNorm.value = mN
|
||||
canvasEl.value?.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
function onDocPointerMove(e: PointerEvent) {
|
||||
if (
|
||||
!drawing.value ||
|
||||
!boxAtStart.value ||
|
||||
!dragStartNorm.value ||
|
||||
!dragMode.value
|
||||
)
|
||||
return
|
||||
const mN = pointerNorm(e)
|
||||
const dx = mN.x - dragStartNorm.value.x
|
||||
const dy = mN.y - dragStartNorm.value.y
|
||||
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
|
||||
state.value.regions[activeIndex.value] = nb
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
function onDocPointerUp(e: PointerEvent) {
|
||||
if (!drawing.value) return
|
||||
drawing.value = false
|
||||
canvasEl.value?.releasePointerCapture?.(e.pointerId)
|
||||
const b = state.value.regions[activeIndex.value]
|
||||
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
|
||||
removeRegion(activeIndex.value)
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
|
||||
function onCanvasPointerMove(e: PointerEvent) {
|
||||
if (drawing.value) onDocPointerMove(e)
|
||||
else onPointerMove(e)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (drawing.value) return
|
||||
const mN = pointerNorm(e)
|
||||
const ti = titleAt(mN)
|
||||
const hit = hitTestPoint(mN)
|
||||
const hb = ti !== null ? ti : hit ? hit.index : null
|
||||
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
|
||||
hoverTagIndex.value = ti
|
||||
hoverIndex.value = hb
|
||||
requestDraw()
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
|
||||
hoverTagIndex.value = null
|
||||
hoverIndex.value = null
|
||||
requestDraw()
|
||||
}
|
||||
}
|
||||
|
||||
const canvasCursor = computed(() =>
|
||||
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
|
||||
)
|
||||
|
||||
function onDoubleClick(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
const mN = pointerNormFromMouse(e)
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const cands = boxesAt(
|
||||
state.value.regions,
|
||||
mN.x,
|
||||
mN.y,
|
||||
HANDLE_PX,
|
||||
W,
|
||||
H,
|
||||
activeIndex.value
|
||||
)
|
||||
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
|
||||
if (!target) return
|
||||
openInlineEditor(target.index)
|
||||
}
|
||||
|
||||
function pointerNormFromMouse(e: MouseEvent) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return { x: 0, y: 0 }
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
x: clampToCanvas((e.clientX - r.left) / r.width),
|
||||
y: clampToCanvas((e.clientY - r.top) / r.height)
|
||||
}
|
||||
}
|
||||
|
||||
function openInlineEditor(index: number) {
|
||||
const b = state.value.regions[index]
|
||||
if (!b) return
|
||||
activeIndex.value = index
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const w = Math.min(W, Math.max(70, b.w * W))
|
||||
const h = Math.min(H, Math.max(42, b.h * H))
|
||||
const left = Math.max(0, Math.min(b.x * W, W - w))
|
||||
const top = Math.max(0, Math.min(b.y * H, H - h))
|
||||
inlineEditor.value = {
|
||||
value: b.desc || '',
|
||||
index,
|
||||
style: {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
|
||||
}
|
||||
}
|
||||
void nextTick(() => {
|
||||
inlineEditorEl.value?.focus()
|
||||
inlineEditorEl.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function onInlineKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
inlineEditor.value = null
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
commitInlineEditor()
|
||||
}
|
||||
}
|
||||
|
||||
function commitInlineEditor() {
|
||||
const ed = inlineEditor.value
|
||||
if (!ed) return
|
||||
const b = state.value.regions[ed.index]
|
||||
if (b) b.desc = ed.value
|
||||
inlineEditor.value = null
|
||||
syncState()
|
||||
}
|
||||
|
||||
function onCanvasKeyDown(e: KeyboardEvent) {
|
||||
if (drawing.value) return
|
||||
const idx = activeIndex.value
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
removeRegion(idx)
|
||||
syncState()
|
||||
}
|
||||
}
|
||||
|
||||
function removeRegion(i: number) {
|
||||
state.value.regions.splice(i, 1)
|
||||
if (!state.value.regions.length) activeIndex.value = -1
|
||||
else if (i <= activeIndex.value)
|
||||
activeIndex.value = Math.max(0, activeIndex.value - 1)
|
||||
}
|
||||
|
||||
function setActiveType(t: 'obj' | 'text') {
|
||||
if (activeRegion.value) {
|
||||
activeRegion.value.type = t
|
||||
syncState()
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
state.value.regions = []
|
||||
activeIndex.value = -1
|
||||
syncState()
|
||||
}
|
||||
|
||||
function syncState() {
|
||||
modelValue.value = toBoundingBoxes(
|
||||
state.value.regions,
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
watch(containerWidth, () => requestDraw())
|
||||
watch(
|
||||
() => state.value.regions.length,
|
||||
() => requestDraw()
|
||||
)
|
||||
watch(isNodeSelected, () => requestDraw())
|
||||
watch([widthValue, heightValue], () => syncState())
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const snap = (v: number) =>
|
||||
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
|
||||
const targetW = snap(naturalWidth)
|
||||
const targetH = snap(naturalHeight)
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && widthWidget.value !== targetW) {
|
||||
widthWidget.value = targetW
|
||||
widthWidget.callback?.(targetW)
|
||||
}
|
||||
if (heightWidget && heightWidget.value !== targetH) {
|
||||
heightWidget.value = targetH
|
||||
heightWidget.callback?.(targetH)
|
||||
}
|
||||
}
|
||||
|
||||
let lastBgUrl = ''
|
||||
function updateBgImage() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const slot = node.findInputSlot('background')
|
||||
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
|
||||
const url = inputNode
|
||||
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
|
||||
: undefined
|
||||
if (!url) {
|
||||
if (bgImage.value) {
|
||||
bgImage.value = null
|
||||
lastBgUrl = ''
|
||||
requestDraw()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (url === lastBgUrl) return
|
||||
lastBgUrl = url
|
||||
const currentUrl = url
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
if (currentUrl !== lastBgUrl) return
|
||||
bgImage.value = img
|
||||
applyImageDimensions(img.naturalWidth, img.naturalHeight)
|
||||
requestDraw()
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
|
||||
|
||||
updateBgImage()
|
||||
void nextTick(() => requestDraw())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafHandle) cancelAnimationFrame(rafHandle)
|
||||
})
|
||||
|
||||
return {
|
||||
canvasStyle,
|
||||
canvasCursor,
|
||||
focused,
|
||||
activeRegion,
|
||||
hasRegions,
|
||||
inlineEditor,
|
||||
maxColors: MAX_ELEMENT_COLORS,
|
||||
onPointerDown,
|
||||
onCanvasPointerMove,
|
||||
onDocPointerUp,
|
||||
onPointerLeave,
|
||||
onDoubleClick,
|
||||
onCanvasKeyDown,
|
||||
onInlineKeyDown,
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
}
|
||||
}
|
||||
114
src/composables/palette/usePaletteSwatchRow.test.ts
Normal file
114
src/composables/palette/usePaletteSwatchRow.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
|
||||
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
|
||||
|
||||
const scopes: EffectScope[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (scopes.length) scopes.pop()?.stop()
|
||||
})
|
||||
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
)!
|
||||
return { modelValue, container, picker, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
const { modelValue, addColor } = setup(['#000000'])
|
||||
addColor()
|
||||
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
|
||||
})
|
||||
|
||||
it('removes a color by index', () => {
|
||||
const { modelValue, remove } = setup(['#a', '#b', '#c'])
|
||||
remove(1)
|
||||
expect(modelValue.value).toEqual(['#a', '#c'])
|
||||
})
|
||||
|
||||
it('seeds the picker input with the clicked color before opening it', () => {
|
||||
const { picker, openPicker } = setup(['#112233'])
|
||||
const click = vi.spyOn(picker.value!, 'click')
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#112233')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to white when the slot is empty', () => {
|
||||
const { picker, openPicker } = setup([''])
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
})
|
||||
|
||||
it('reorders via drag when the pointer crosses another swatch', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
for (const i of [0, 1]) {
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', String(i))
|
||||
container.value!.appendChild(swatch)
|
||||
}
|
||||
const second = container.value!.children[1] as HTMLDivElement
|
||||
second.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
expect(modelValue.value).toEqual(['#b', '#a'])
|
||||
})
|
||||
|
||||
it('cancels a stale drag when the primary button is no longer pressed', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
for (const i of [0, 1]) {
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', String(i))
|
||||
container.value!.appendChild(swatch)
|
||||
}
|
||||
const second = container.value!.children[1] as HTMLDivElement
|
||||
second.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
|
||||
)
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores non-left-button pointer downs', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
|
||||
)
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
})
|
||||
114
src/composables/palette/usePaletteSwatchRow.ts
Normal file
114
src/composables/palette/usePaletteSwatchRow.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface UsePaletteSwatchRowOptions {
|
||||
modelValue: Ref<string[]>
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
picker: Readonly<ShallowRef<HTMLInputElement | null>>
|
||||
}
|
||||
|
||||
export function usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container,
|
||||
picker
|
||||
}: UsePaletteSwatchRowOptions) {
|
||||
const pickerIndex = ref<number | null>(null)
|
||||
|
||||
function openPicker(i: number, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
pickerIndex.value = i
|
||||
const el = picker.value
|
||||
if (!el) return
|
||||
el.value = modelValue.value[i] || '#ffffff'
|
||||
el.click()
|
||||
}
|
||||
|
||||
function onPickerInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (pickerIndex.value === null) return
|
||||
const next = modelValue.value.slice()
|
||||
next[pickerIndex.value] = v
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
function remove(i: number) {
|
||||
const next = modelValue.value.slice()
|
||||
next.splice(i, 1)
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
function addColor() {
|
||||
modelValue.value = [...modelValue.value, '#ffffff']
|
||||
}
|
||||
|
||||
const drag = ref<{
|
||||
index: number
|
||||
startX: number
|
||||
startY: number
|
||||
active: boolean
|
||||
} | null>(null)
|
||||
|
||||
function onPointerDown(i: number, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
drag.value = {
|
||||
index: i,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
active: false
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
const d = drag.value
|
||||
if (!d) return
|
||||
if ((e.buttons & 1) === 0) {
|
||||
drag.value = null
|
||||
return
|
||||
}
|
||||
if (!d.active) {
|
||||
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
|
||||
return
|
||||
d.active = true
|
||||
}
|
||||
const rows =
|
||||
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
|
||||
if (!rows) return
|
||||
for (const other of rows) {
|
||||
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
|
||||
const r = other.getBoundingClientRect()
|
||||
if (
|
||||
e.clientX >= r.left &&
|
||||
e.clientX <= r.right &&
|
||||
e.clientY >= r.top - 6 &&
|
||||
e.clientY <= r.bottom + 6
|
||||
) {
|
||||
const oi = parseInt(other.dataset.index || '-1', 10)
|
||||
if (oi < 0) continue
|
||||
const next = modelValue.value.slice()
|
||||
const [moved] = next.splice(d.index, 1)
|
||||
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
|
||||
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
|
||||
modelValue.value = next
|
||||
drag.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(document, 'pointerup', () => {
|
||||
drag.value = null
|
||||
})
|
||||
|
||||
useEventListener(document, 'pointercancel', () => {
|
||||
drag.value = null
|
||||
})
|
||||
|
||||
return {
|
||||
openPicker,
|
||||
onPickerInput,
|
||||
remove,
|
||||
addColor,
|
||||
onPointerDown
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { render } from '@testing-library/vue'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobState } from '@/types/queue'
|
||||
@@ -19,32 +20,24 @@ type TestTask = {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'queue.jobList.undated': 'Undated',
|
||||
'g.emDash': '--',
|
||||
'g.untitled': 'Untitled'
|
||||
}
|
||||
let localeRef: Ref<string>
|
||||
let tMock: ReturnType<typeof vi.fn>
|
||||
const ensureLocaleMocks = () => {
|
||||
if (!localeRef) {
|
||||
localeRef = ref('en-US') as Ref<string>
|
||||
}
|
||||
if (!tMock) {
|
||||
tMock = vi.fn((key: string) => translations[key] ?? key)
|
||||
}
|
||||
return { localeRef, tMock }
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => {
|
||||
ensureLocaleMocks()
|
||||
return {
|
||||
t: tMock,
|
||||
locale: localeRef
|
||||
const createTestI18n = () =>
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en-US',
|
||||
messages: {
|
||||
'en-US': {
|
||||
queue: {
|
||||
jobList: {
|
||||
undated: 'Undated'
|
||||
}
|
||||
},
|
||||
g: {
|
||||
emDash: '--',
|
||||
untitled: 'Untitled'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
|
||||
@@ -184,13 +177,20 @@ const createTask = (
|
||||
|
||||
const mountUseJobList = () => {
|
||||
let composable: ReturnType<typeof useJobList>
|
||||
const result = render({
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useJobList()
|
||||
return {}
|
||||
const result = render(
|
||||
{
|
||||
template: '<div />',
|
||||
setup() {
|
||||
composable = useJobList()
|
||||
return {}
|
||||
}
|
||||
},
|
||||
{
|
||||
global: {
|
||||
plugins: [createTestI18n()]
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
return { ...result, composable: composable! }
|
||||
}
|
||||
|
||||
@@ -215,10 +215,6 @@ const resetStores = () => {
|
||||
totalPercent.value = 0
|
||||
currentNodePercent.value = 0
|
||||
|
||||
ensureLocaleMocks()
|
||||
localeRef.value = 'en-US'
|
||||
tMock.mockClear()
|
||||
|
||||
if (isJobInitializingMock) {
|
||||
vi.mocked(isJobInitializingMock).mockReset()
|
||||
vi.mocked(isJobInitializingMock).mockReturnValue(false)
|
||||
@@ -561,6 +557,35 @@ describe('useJobList', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups terminal jobs without an execution end timestamp by create time', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
queueStoreMock.historyTasks = [
|
||||
createTask({
|
||||
jobId: 'failed-before-execution',
|
||||
job: { priority: 1 },
|
||||
mockState: 'failed',
|
||||
createTime: Date.now()
|
||||
}),
|
||||
createTask({
|
||||
jobId: 'completed-without-end-time',
|
||||
job: { priority: 1 },
|
||||
mockState: 'completed',
|
||||
createTime: Date.now() - 1_000
|
||||
})
|
||||
]
|
||||
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
|
||||
const groups = instance.groupedJobItems.value
|
||||
expect(groups.map((g) => g.label)).toEqual(['Today'])
|
||||
expect(groups[0].items.map((item) => item.id)).toEqual([
|
||||
'failed-before-execution',
|
||||
'completed-without-end-time'
|
||||
])
|
||||
})
|
||||
|
||||
it('groups job items by date label and sorts by total generation time when requested', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
|
||||
|
||||
@@ -341,7 +341,7 @@ export function useJobList() {
|
||||
for (const { task, state } of searchableTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp
|
||||
ts = task.executionEndTimestamp ?? task.createTime
|
||||
} else {
|
||||
ts = task.createTime
|
||||
}
|
||||
|
||||
@@ -1,105 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCopy } from './useCopy'
|
||||
|
||||
/**
|
||||
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
|
||||
*/
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(
|
||||
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
|
||||
const copyMocks = vi.hoisted(() => ({
|
||||
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
|
||||
canvas: {
|
||||
selectedItems: new Set<object>([{}]),
|
||||
copyToClipboard: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn(
|
||||
(
|
||||
_target: EventTarget,
|
||||
event: string,
|
||||
handler: (event: ClipboardEvent) => unknown
|
||||
) => {
|
||||
if (event === 'copy') copyMocks.copyHandler = handler
|
||||
return vi.fn()
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: copyMocks.canvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/eventHelpers', () => ({
|
||||
shouldIgnoreCopyPaste: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const multiChunkPayloadLength = 0x8000 * 6 + 123
|
||||
|
||||
function copySerializedData(serializedData: string): DataTransfer {
|
||||
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
|
||||
|
||||
useCopy()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
const event = new ClipboardEvent('copy', {
|
||||
clipboardData: dataTransfer
|
||||
})
|
||||
const copyHandler = copyMocks.copyHandler
|
||||
expect(copyHandler).toBeDefined()
|
||||
if (!copyHandler) throw new Error('Expected copy handler to be registered')
|
||||
|
||||
expect(() => copyHandler(event)).not.toThrow()
|
||||
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
|
||||
*/
|
||||
function decodeClipboardData(base64: string): string {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
|
||||
const match = dataTransfer
|
||||
.getData('text/html')
|
||||
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
expect(match).toBeDefined()
|
||||
if (!match) throw new Error('Expected clipboard metadata to be written')
|
||||
|
||||
const binaryString = atob(match)
|
||||
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
|
||||
it('should handle ASCII-only strings', () => {
|
||||
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
describe('useCopy', () => {
|
||||
beforeEach(() => {
|
||||
copyMocks.copyHandler = undefined
|
||||
copyMocks.canvas.copyToClipboard.mockReset()
|
||||
})
|
||||
|
||||
it('should handle Chinese characters in localized_name', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Japanese characters', () => {
|
||||
const original = '{"localized_name":"画像を読み込む"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Korean characters', () => {
|
||||
const original = '{"localized_name":"이미지 불러오기"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle mixed ASCII and Unicode characters', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle emoji characters', () => {
|
||||
const original = '{"title":"Test Node 🎨🖼️"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const original = ''
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle complex node data with multiple Unicode fields', () => {
|
||||
const original = JSON.stringify({
|
||||
it('should write large serialized node data to clipboard metadata', () => {
|
||||
const serializedData = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
localized_name: '图像',
|
||||
inputs: [{ localized_name: '图片', name: 'image' }],
|
||||
outputs: [{ localized_name: '输出', name: 'output' }]
|
||||
type: 'Subgraph',
|
||||
title: 'Large Subgraph',
|
||||
localized_name: '이미지 그룹 图像 🎨',
|
||||
payload: 'x'.repeat(multiChunkPayloadLength)
|
||||
}
|
||||
],
|
||||
groups: [{ title: '预处理组 🔧' }],
|
||||
links: []
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: []
|
||||
})
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
|
||||
})
|
||||
|
||||
it('should produce valid base64 output', () => {
|
||||
const original = '{"localized_name":"中文测试"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
// Base64 should only contain valid characters
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
const dataTransfer = copySerializedData(serializedData)
|
||||
|
||||
it('should fail with plain btoa for non-Latin1 characters', () => {
|
||||
const original = '{"localized_name":"图像"}'
|
||||
// This demonstrates why we need TextEncoder - plain btoa fails
|
||||
expect(() => btoa(original)).toThrow()
|
||||
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,29 @@ const clipboardHTMLWrapper = [
|
||||
'<meta charset="utf-8"><div><span data-metadata="',
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
]
|
||||
const clipboardByteChunkSize = 0x8000
|
||||
|
||||
function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
const chunks: string[] = []
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < bytes.length;
|
||||
offset += clipboardByteChunkSize
|
||||
) {
|
||||
chunks.push(
|
||||
String.fromCharCode(
|
||||
...bytes.subarray(offset, offset + clipboardByteChunkSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on copy that serializes selected nodes to JSON
|
||||
@@ -23,17 +46,16 @@ export const useCopy = () => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas?.selectedItems) {
|
||||
const serializedData = canvas.copyToClipboard()
|
||||
// Use TextEncoder to handle Unicode characters properly
|
||||
const base64Data = btoa(
|
||||
String.fromCharCode(
|
||||
...Array.from(new TextEncoder().encode(serializedData))
|
||||
try {
|
||||
const base64Data = encodeClipboardData(serializedData)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
return false
|
||||
|
||||
443
src/composables/useHdrViewer.ts
Normal file
443
src/composables/useHdrViewer.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import * as THREE from 'three'
|
||||
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
|
||||
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
|
||||
import {
|
||||
detectGamutFromChromaticities,
|
||||
gamutToSrgbMatrix
|
||||
} from '@/renderer/hdr/colorGamut'
|
||||
import {
|
||||
HDR_VIEWER_FRAGMENT_SHADER,
|
||||
HDR_VIEWER_VERTEX_SHADER
|
||||
} from '@/renderer/hdr/hdrViewerShader'
|
||||
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
|
||||
import {
|
||||
computeChannelHistograms,
|
||||
computeImageStats
|
||||
} from '@/renderer/hdr/hdrStats'
|
||||
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
|
||||
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
|
||||
|
||||
const MIN_ZOOM = 0.05
|
||||
const MAX_ZOOM = 64
|
||||
|
||||
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
|
||||
|
||||
export const CHANNEL_MODES: ChannelMode[] = [
|
||||
'rgb',
|
||||
'r',
|
||||
'g',
|
||||
'b',
|
||||
'a',
|
||||
'luminance'
|
||||
]
|
||||
|
||||
const CHANNEL_INDEX: Record<ChannelMode, number> = {
|
||||
rgb: 0,
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3,
|
||||
a: 4,
|
||||
luminance: 5
|
||||
}
|
||||
|
||||
interface PixelReadout {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number | null
|
||||
}
|
||||
|
||||
interface ExrTexData {
|
||||
header?: { chromaticities?: ChromaticityCoords }
|
||||
}
|
||||
|
||||
function createLoader(url: string) {
|
||||
const filename = getImageFilenameFromUrl(url)
|
||||
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
|
||||
const loader = new EXRLoader()
|
||||
loader.setDataType(THREE.FloatType)
|
||||
return loader
|
||||
}
|
||||
|
||||
function makeReader(
|
||||
data: ArrayLike<number>,
|
||||
type: THREE.TextureDataType
|
||||
): (index: number) => number {
|
||||
if (type === THREE.HalfFloatType) {
|
||||
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
|
||||
}
|
||||
return (index) => data[index]
|
||||
}
|
||||
|
||||
function loadHdrTexture(
|
||||
url: string
|
||||
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
createLoader(url).load(
|
||||
url,
|
||||
(texture, texData) => {
|
||||
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
|
||||
resolve({
|
||||
texture,
|
||||
gamut: detectGamutFromChromaticities(chromaticities)
|
||||
})
|
||||
},
|
||||
undefined,
|
||||
reject
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function useHdrViewer() {
|
||||
const exposureStops = ref(0)
|
||||
const dither = ref(true)
|
||||
const clipWarnings = ref(false)
|
||||
const gamut = ref<GamutName>('sRGB')
|
||||
const channel = ref<ChannelMode>('rgb')
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const dimensions = ref<string | null>(null)
|
||||
const stats = ref<ImageStats | null>(null)
|
||||
const histograms = shallowRef<ChannelHistograms | null>(null)
|
||||
const pixel = ref<PixelReadout | null>(null)
|
||||
|
||||
const histogram = computed<Uint32Array | null>(() => {
|
||||
const channelHistograms = histograms.value
|
||||
if (!channelHistograms) return null
|
||||
switch (channel.value) {
|
||||
case 'r':
|
||||
return channelHistograms.r
|
||||
case 'g':
|
||||
return channelHistograms.g
|
||||
case 'b':
|
||||
return channelHistograms.b
|
||||
case 'a':
|
||||
return channelHistograms.a
|
||||
default:
|
||||
return channelHistograms.luminance
|
||||
}
|
||||
})
|
||||
|
||||
const containerRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let viewport: WebGLViewport | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.OrthographicCamera | null = null
|
||||
let material: THREE.ShaderMaterial | null = null
|
||||
let mesh: THREE.Mesh | null = null
|
||||
let texture: THREE.Texture | null = null
|
||||
let imageAspect = 1
|
||||
let frameRequested = false
|
||||
|
||||
let readSample: ((index: number) => number) | null = null
|
||||
let imageWidth = 0
|
||||
let imageHeight = 0
|
||||
let imageChannels = 4
|
||||
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const pointerNdc = new THREE.Vector2()
|
||||
|
||||
function requestRender() {
|
||||
if (!renderer || frameRequested) return
|
||||
frameRequested = true
|
||||
requestAnimationFrame(() => {
|
||||
frameRequested = false
|
||||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||||
})
|
||||
}
|
||||
|
||||
function containerSize() {
|
||||
const el = containerRef.value
|
||||
return {
|
||||
width: el?.clientWidth || 1,
|
||||
height: el?.clientHeight || 1
|
||||
}
|
||||
}
|
||||
|
||||
function updateProjection() {
|
||||
if (!camera) return
|
||||
const { width, height } = containerSize()
|
||||
const halfH = 0.5
|
||||
const halfW = (0.5 * width) / height
|
||||
camera.left = -halfW
|
||||
camera.right = halfW
|
||||
camera.top = halfH
|
||||
camera.bottom = -halfH
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
if (!camera) return
|
||||
const { width, height } = containerSize()
|
||||
const containerAspect = width / height
|
||||
camera.zoom = Math.min(1, containerAspect / imageAspect)
|
||||
camera.position.set(0, 0, 1)
|
||||
camera.updateProjectionMatrix()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function applyUniforms() {
|
||||
if (!material) return
|
||||
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
|
||||
material.uniforms.uDither.value = dither.value
|
||||
material.uniforms.uClipWarnings.value = clipWarnings.value
|
||||
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
|
||||
const m = gamutToSrgbMatrix(gamut.value)
|
||||
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
|
||||
m[0],
|
||||
m[1],
|
||||
m[2],
|
||||
m[3],
|
||||
m[4],
|
||||
m[5],
|
||||
m[6],
|
||||
m[7],
|
||||
m[8]
|
||||
)
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function buildScene() {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
|
||||
viewport = new WebGLViewport(renderer)
|
||||
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
renderer.setPixelRatio(window.devicePixelRatio)
|
||||
renderer.setClearColor(0x0a0a0a, 1)
|
||||
|
||||
scene = new THREE.Scene()
|
||||
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
|
||||
camera.position.set(0, 0, 1)
|
||||
|
||||
material = new THREE.ShaderMaterial({
|
||||
glslVersion: THREE.GLSL3,
|
||||
vertexShader: HDR_VIEWER_VERTEX_SHADER,
|
||||
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
|
||||
uniforms: {
|
||||
uImage: { value: null },
|
||||
uGamutToSRGB: { value: new THREE.Matrix3() },
|
||||
uGain: { value: 1 },
|
||||
uChannel: { value: 0 },
|
||||
uDither: { value: true },
|
||||
uClipWarnings: { value: false },
|
||||
uClipRange: { value: new THREE.Vector2(0, 1) }
|
||||
}
|
||||
})
|
||||
|
||||
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
|
||||
scene.add(mesh)
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!renderer) return
|
||||
const { width, height } = containerSize()
|
||||
renderer.setSize(width, height, false)
|
||||
updateProjection()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function setTexture(loaded: THREE.DataTexture) {
|
||||
if (!material || !mesh) return
|
||||
loaded.colorSpace = THREE.LinearSRGBColorSpace
|
||||
loaded.minFilter = THREE.LinearFilter
|
||||
loaded.magFilter = THREE.LinearFilter
|
||||
loaded.needsUpdate = true
|
||||
|
||||
const { width, height, data } = loaded.image
|
||||
texture = loaded
|
||||
imageAspect = width / height
|
||||
mesh.scale.set(imageAspect, 1, 1)
|
||||
material.uniforms.uImage.value = loaded
|
||||
dimensions.value = `${width} x ${height}`
|
||||
|
||||
if (!data) return
|
||||
imageWidth = width
|
||||
imageHeight = height
|
||||
imageChannels = data.length / (width * height)
|
||||
readSample = makeReader(data, loaded.type)
|
||||
stats.value = computeImageStats(readSample, data.length, imageChannels)
|
||||
histograms.value = computeChannelHistograms(
|
||||
readSample,
|
||||
data.length,
|
||||
imageChannels
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(container: HTMLElement, url: string) {
|
||||
containerRef.value = container
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
buildScene()
|
||||
container.appendChild(renderer!.domElement)
|
||||
renderer!.domElement.classList.add('block', 'size-full')
|
||||
resize()
|
||||
applyUniforms()
|
||||
attachInteractions(renderer!.domElement)
|
||||
viewport!.observeResize(container, resize)
|
||||
|
||||
const { texture: loaded, gamut: detectedGamut } =
|
||||
await loadHdrTexture(url)
|
||||
if (!material || !mesh) {
|
||||
loaded.dispose()
|
||||
return
|
||||
}
|
||||
gamut.value = detectedGamut
|
||||
setTexture(loaded)
|
||||
applyUniforms()
|
||||
fitView()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
dispose()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExposure() {
|
||||
const max = stats.value?.max ?? 0
|
||||
exposureStops.value = max > 0 ? -Math.log2(max) : 0
|
||||
}
|
||||
|
||||
function attachInteractions(canvas: HTMLCanvasElement) {
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||
canvas.addEventListener('pointerdown', onPointerDown)
|
||||
canvas.addEventListener('pointermove', onHoverMove)
|
||||
canvas.addEventListener('pointerleave', onHoverLeave)
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
if (!camera) return
|
||||
event.preventDefault()
|
||||
const factor = Math.exp(-event.deltaY * 0.001)
|
||||
const nextZoom = THREE.MathUtils.clamp(
|
||||
camera.zoom * factor,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
camera.zoom = nextZoom
|
||||
camera.updateProjectionMatrix()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
|
||||
null
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!camera) return
|
||||
dragStart = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
camX: camera.position.x,
|
||||
camY: camera.position.y
|
||||
}
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!camera || !dragStart) return
|
||||
const { height } = containerSize()
|
||||
const worldPerPixel = 1 / (height * camera.zoom)
|
||||
camera.position.x =
|
||||
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
|
||||
camera.position.y =
|
||||
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragStart = null
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onHoverMove(event: PointerEvent) {
|
||||
if (!camera || !mesh || !renderer || dragStart || !readSample) return
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
raycaster.setFromCamera(pointerNdc, camera)
|
||||
const hit = raycaster.intersectObject(mesh)[0]
|
||||
if (!hit?.uv) {
|
||||
pixel.value = null
|
||||
return
|
||||
}
|
||||
const col = THREE.MathUtils.clamp(
|
||||
Math.floor(hit.uv.x * imageWidth),
|
||||
0,
|
||||
imageWidth - 1
|
||||
)
|
||||
const row = THREE.MathUtils.clamp(
|
||||
Math.floor(hit.uv.y * imageHeight),
|
||||
0,
|
||||
imageHeight - 1
|
||||
)
|
||||
const base = (row * imageWidth + col) * imageChannels
|
||||
pixel.value = {
|
||||
x: col,
|
||||
y: imageHeight - 1 - row,
|
||||
r: readSample(base),
|
||||
g: readSample(base + 1),
|
||||
b: readSample(base + 2),
|
||||
a: imageChannels === 4 ? readSample(base + 3) : null
|
||||
}
|
||||
}
|
||||
|
||||
function onHoverLeave() {
|
||||
pixel.value = null
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
|
||||
if (renderer) {
|
||||
renderer.domElement.removeEventListener('wheel', onWheel)
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
|
||||
renderer.domElement.removeEventListener('pointermove', onHoverMove)
|
||||
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
|
||||
}
|
||||
viewport?.disposeRenderer()
|
||||
texture?.dispose()
|
||||
material?.dispose()
|
||||
mesh?.geometry.dispose()
|
||||
|
||||
renderer = null
|
||||
viewport = null
|
||||
scene = null
|
||||
camera = null
|
||||
material = null
|
||||
mesh = null
|
||||
texture = null
|
||||
readSample = null
|
||||
}
|
||||
|
||||
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
|
||||
|
||||
onUnmounted(dispose)
|
||||
|
||||
return {
|
||||
exposureStops,
|
||||
dither,
|
||||
clipWarnings,
|
||||
gamut,
|
||||
channel,
|
||||
loading,
|
||||
error,
|
||||
dimensions,
|
||||
stats,
|
||||
histogram,
|
||||
pixel,
|
||||
mount,
|
||||
dispose,
|
||||
fitView,
|
||||
normalizeExposure
|
||||
}
|
||||
}
|
||||
89
src/extensions/core/createBoundingBoxes.test.ts
Normal file
89
src/extensions/core/createBoundingBoxes.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { state } = vi.hoisted(() => ({
|
||||
state: {
|
||||
extension: null as { nodeCreated: (node: unknown) => void } | null,
|
||||
widgetState: undefined as { options: Record<string, unknown> } | undefined
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: (ext: { nodeCreated: (node: unknown) => void }) => {
|
||||
state.extension = ext
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: () => state.widgetState })
|
||||
}))
|
||||
|
||||
await import('./createBoundingBoxes')
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
hidden: boolean
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
|
||||
const widgets: MockWidget[] = [
|
||||
{ name: 'width', hidden: false, options: {} },
|
||||
{ name: 'height', hidden: false, options: {} },
|
||||
{ name: 'other', hidden: false, options: {} }
|
||||
]
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
id: 1,
|
||||
graph: { rootGraph: { id: 'test-graph' } },
|
||||
size: [100, 100] as [number, number],
|
||||
setSize: vi.fn(),
|
||||
findInputSlot: () => 0,
|
||||
isInputConnected: () => connected,
|
||||
widgets,
|
||||
onConnectionsChange: undefined as unknown
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.widgetState = undefined
|
||||
})
|
||||
|
||||
describe('Comfy.CreateBoundingBoxes extension', () => {
|
||||
it('ignores nodes of other classes', () => {
|
||||
const node = makeNode(true, 'SomethingElse')
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.setSize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enlarges the node and hides width/height when a background is connected', () => {
|
||||
const node = makeNode(true)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.setSize).toHaveBeenCalledWith([420, 560])
|
||||
expect(node.widgets[0].hidden).toBe(true)
|
||||
expect(node.widgets[1].hidden).toBe(true)
|
||||
expect(node.widgets[0].options.hidden).toBe(true)
|
||||
expect(node.widgets[2].hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('shows width/height when no background is connected', () => {
|
||||
const node = makeNode(false)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.widgets[0].hidden).toBe(false)
|
||||
expect(node.widgets[0].options.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('writes visibility through the widget value store when present', () => {
|
||||
state.widgetState = { options: {} }
|
||||
const node = makeNode(true)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(state.widgetState.options.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('chains a connections-change handler that re-syncs visibility', () => {
|
||||
const node = makeNode(false)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(typeof node.onConnectionsChange).toBe('function')
|
||||
})
|
||||
})
|
||||
40
src/extensions/core/createBoundingBoxes.ts
Normal file
40
src/extensions/core/createBoundingBoxes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const DIMENSION_WIDGETS = new Set(['width', 'height'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateBoundingBoxes',
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'CreateBoundingBoxes') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 420), Math.max(oldHeight, 560)])
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const syncDimensionVisibility = () => {
|
||||
const slot = node.findInputSlot('background')
|
||||
const hidden = slot >= 0 && node.isInputConnected(slot)
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (!DIMENSION_WIDGETS.has(widget.name)) continue
|
||||
widget.hidden = hidden
|
||||
const state = graphId
|
||||
? widgetValueStore.getWidget(graphId, node.id, widget.name)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
syncDimensionVisibility()
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
syncDimensionVisibility
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './createBoundingBoxes'
|
||||
import './customWidgets'
|
||||
import './dynamicPrompts'
|
||||
import './editAttention'
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('three/examples/jsm/controls/OrbitControls', () => {
|
||||
object: THREE.Camera
|
||||
domElement: HTMLElement
|
||||
enableDamping = false
|
||||
enabled = true
|
||||
target = new THREE.Vector3()
|
||||
update = vi.fn()
|
||||
dispose = vi.fn()
|
||||
@@ -165,4 +166,24 @@ describe('ControlsManager', () => {
|
||||
expect(manager.controls.dispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detach / attach', () => {
|
||||
it('detach disables OrbitControls interaction', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
expect(manager.controls.enabled).toBe(true)
|
||||
|
||||
manager.detach()
|
||||
|
||||
expect(manager.controls.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('attach re-enables OrbitControls interaction', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager.detach()
|
||||
|
||||
manager.attach()
|
||||
|
||||
expect(manager.controls.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +63,15 @@ export class ControlsManager implements ControlsManagerInterface {
|
||||
camera.position.copy(position)
|
||||
this.controls.update()
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.controls.enabled = false
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
this.controls.enabled = true
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.controls.target.set(0, 0, 0)
|
||||
this.controls.update()
|
||||
|
||||
@@ -2,26 +2,19 @@ import * as THREE from 'three'
|
||||
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'
|
||||
|
||||
import type { AnimationManager } from './AnimationManager'
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
import type { EventManager } from './EventManager'
|
||||
import type { GizmoManager } from './GizmoManager'
|
||||
import type { HDRIManager } from './HDRIManager'
|
||||
import type { LightingManager } from './LightingManager'
|
||||
import type { LoaderManager } from './LoaderManager'
|
||||
import { DIRECT_EXPORT_FORMATS } from './constants'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
|
||||
import type { RecordingManager } from './RecordingManager'
|
||||
import type { SceneManager } from './SceneManager'
|
||||
import type { SceneModelManager } from './SceneModelManager'
|
||||
import type { ViewHelperManager } from './ViewHelperManager'
|
||||
import { Viewport3d, type Viewport3dDeps } from './Viewport3d'
|
||||
import { computeCameraFromMatrices } from './cameraFromMatrices'
|
||||
import { DIRECT_EXPORT_FORMATS } from './constants'
|
||||
import type {
|
||||
CameraState,
|
||||
CaptureResult,
|
||||
EventCallback,
|
||||
GizmoMode,
|
||||
Load3DOptions,
|
||||
LoadModelOptions,
|
||||
@@ -29,20 +22,10 @@ import type {
|
||||
Model3DTransform,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
|
||||
export type Load3dDeps = {
|
||||
renderer: THREE.WebGLRenderer
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
export type Load3dDeps = Viewport3dDeps & {
|
||||
hdriManager: HDRIManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
loaderManager: LoaderManager
|
||||
modelManager: SceneModelManager
|
||||
recordingManager: RecordingManager
|
||||
@@ -70,22 +53,8 @@ function positionThumbnailCamera(
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
class Load3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private _loadGeneration: number = 0
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
class Load3d extends Viewport3d {
|
||||
hdriManager: HDRIManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
loaderManager: LoaderManager
|
||||
modelManager: SceneModelManager
|
||||
recordingManager: RecordingManager
|
||||
@@ -93,19 +62,8 @@ class Load3d {
|
||||
gizmoManager: GizmoManager
|
||||
adapterRef: AdapterRef
|
||||
|
||||
STATUS_MOUSE_ON_NODE: boolean
|
||||
STATUS_MOUSE_ON_SCENE: boolean
|
||||
STATUS_MOUSE_ON_VIEWER: boolean
|
||||
INITIAL_RENDER_DONE: boolean = false
|
||||
|
||||
targetWidth: number = 0
|
||||
targetHeight: number = 0
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private _loadGeneration: number = 0
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
@@ -113,26 +71,9 @@ class Load3d {
|
||||
deps: Load3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
this.clock = new THREE.Clock()
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
this.getDimensionsCallback = options.getDimensions
|
||||
this.getZoomScaleCallback = options.getZoomScale
|
||||
super(container, deps, options)
|
||||
|
||||
if (options.width && options.height) {
|
||||
this.targetWidth = options.width
|
||||
this.targetHeight = options.height
|
||||
this.targetAspectRatio = options.width / options.height
|
||||
}
|
||||
|
||||
this.renderer = deps.renderer
|
||||
this.eventManager = deps.eventManager
|
||||
this.sceneManager = deps.sceneManager
|
||||
this.cameraManager = deps.cameraManager
|
||||
this.controlsManager = deps.controlsManager
|
||||
this.lightingManager = deps.lightingManager
|
||||
this.hdriManager = deps.hdriManager
|
||||
this.viewHelperManager = deps.viewHelperManager
|
||||
this.loaderManager = deps.loaderManager
|
||||
this.modelManager = deps.modelManager
|
||||
this.recordingManager = deps.recordingManager
|
||||
@@ -140,35 +81,16 @@ class Load3d {
|
||||
this.gizmoManager = deps.gizmoManager
|
||||
this.adapterRef = deps.adapterRef
|
||||
|
||||
this.sceneManager.init()
|
||||
this.cameraManager.init()
|
||||
this.controlsManager.init()
|
||||
this.lightingManager.init()
|
||||
this.loaderManager.init()
|
||||
this.animationManager.init()
|
||||
this.gizmoManager.init()
|
||||
|
||||
this.viewHelperManager.createViewHelper(container)
|
||||
this.viewHelperManager.init()
|
||||
|
||||
this.STATUS_MOUSE_ON_NODE = false
|
||||
this.STATUS_MOUSE_ON_SCENE = false
|
||||
this.STATUS_MOUSE_ON_VIEWER = false
|
||||
|
||||
this.initContextMenu()
|
||||
this.initResizeObserver(container)
|
||||
|
||||
this.handleResize()
|
||||
this.startAnimation()
|
||||
|
||||
this.eventManager.addEventListener('modelReady', () => {
|
||||
if (this.adapterRef.current?.kind !== 'splat') return
|
||||
void this.repaintWhenSparkPaintable()
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.forceRender()
|
||||
}, 100)
|
||||
this.start()
|
||||
}
|
||||
|
||||
private async repaintWhenSparkPaintable(): Promise<void> {
|
||||
@@ -178,49 +100,14 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
private initResizeObserver(container: Element | HTMLElement): void {
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize()
|
||||
})
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
private initContextMenu(): void {
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
(event) => this.onContextMenuCallback?.(event),
|
||||
{ isDisabled: () => this.isViewerMode }
|
||||
)
|
||||
}
|
||||
|
||||
getEventManager(): EventManager {
|
||||
return this.eventManager
|
||||
}
|
||||
|
||||
getSceneManager(): SceneManager {
|
||||
return this.sceneManager
|
||||
}
|
||||
getCameraManager(): CameraManager {
|
||||
return this.cameraManager
|
||||
}
|
||||
getControlsManager(): ControlsManager {
|
||||
return this.controlsManager
|
||||
}
|
||||
getLightingManager(): LightingManager {
|
||||
return this.lightingManager
|
||||
}
|
||||
getViewHelperManager(): ViewHelperManager {
|
||||
return this.viewHelperManager
|
||||
}
|
||||
getLoaderManager(): LoaderManager {
|
||||
return this.loaderManager
|
||||
}
|
||||
|
||||
getModelManager(): SceneModelManager {
|
||||
return this.modelManager
|
||||
}
|
||||
|
||||
getRecordingManager(): RecordingManager {
|
||||
return this.recordingManager
|
||||
}
|
||||
@@ -229,119 +116,12 @@ class Load3d {
|
||||
return this.gizmoManager
|
||||
}
|
||||
|
||||
getTargetSize(): { width: number; height: number } {
|
||||
return {
|
||||
width: this.targetWidth,
|
||||
height: this.targetHeight
|
||||
}
|
||||
}
|
||||
|
||||
private shouldMaintainAspectRatio(): boolean {
|
||||
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
|
||||
}
|
||||
|
||||
forceRender(): void {
|
||||
const delta = this.clock.getDelta()
|
||||
protected override tickPerFrame(delta: number): void {
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
|
||||
this.INITIAL_RENDER_DONE = true
|
||||
super.tickPerFrame(delta)
|
||||
}
|
||||
|
||||
renderMainScene(): void {
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
if (dims) {
|
||||
this.targetWidth = dims.width
|
||||
this.targetHeight = dims.height
|
||||
this.targetAspectRatio = dims.width / dims.height
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
this.renderer.setClearColor(0x0a0a0a)
|
||||
this.renderer.clear()
|
||||
|
||||
this.renderer.setViewport(offsetX, offsetY, width, height)
|
||||
this.renderer.setScissor(offsetX, offsetY, width, height)
|
||||
|
||||
this.cameraManager.updateAspectRatio(width / height)
|
||||
} else {
|
||||
// No aspect ratio constraint: fill the entire container
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
}
|
||||
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
}
|
||||
|
||||
resetViewport(): void {
|
||||
const width = this.renderer.domElement.clientWidth
|
||||
const height = this.renderer.domElement.clientHeight
|
||||
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
const delta = this.clock.getDelta()
|
||||
this.animationManager.update(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
},
|
||||
isActive: () => this.isActive()
|
||||
})
|
||||
}
|
||||
|
||||
updateStatusMouseOnNode(onNode: boolean): void {
|
||||
this.STATUS_MOUSE_ON_NODE = onNode
|
||||
}
|
||||
|
||||
updateStatusMouseOnScene(onScene: boolean): void {
|
||||
this.STATUS_MOUSE_ON_SCENE = onScene
|
||||
}
|
||||
|
||||
updateStatusMouseOnViewer(onViewer: boolean): void {
|
||||
this.STATUS_MOUSE_ON_VIEWER = onViewer
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
override isActive(): boolean {
|
||||
return isLoad3dActive({
|
||||
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
|
||||
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
|
||||
@@ -444,9 +224,27 @@ class Load3d {
|
||||
return ModelExporter.detectFormatFromURL(url)
|
||||
}
|
||||
|
||||
protected override onActiveCameraChanged(): void {
|
||||
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
|
||||
}
|
||||
|
||||
setFOV(fov: number): void {
|
||||
this.cameraManager.setFOV(fov)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.sceneManager.setBackgroundColor(color)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
toggleGrid(showGrid: boolean): void {
|
||||
this.sceneManager.toggleGrid(showGrid)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setLightIntensity(intensity: number): void {
|
||||
this.lightingManager.setLightIntensity(intensity)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -473,7 +271,6 @@ class Load3d {
|
||||
height
|
||||
)
|
||||
} else {
|
||||
// No aspect ratio constraints: fill container
|
||||
this.sceneManager.updateBackgroundSize(
|
||||
this.sceneManager.backgroundTexture,
|
||||
this.sceneManager.backgroundMesh,
|
||||
@@ -488,12 +285,6 @@ class Load3d {
|
||||
|
||||
removeBackgroundImage(): void {
|
||||
this.sceneManager.removeBackgroundImage()
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
toggleGrid(showGrid: boolean): void {
|
||||
this.sceneManager.toggleGrid(showGrid)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -502,39 +293,6 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
|
||||
this.cameraManager.toggleCamera(cameraType)
|
||||
|
||||
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
|
||||
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
|
||||
this.viewHelperManager.recreateViewHelper()
|
||||
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
getCurrentCameraType(): 'perspective' | 'orthographic' {
|
||||
return this.cameraManager.getCurrentCameraType()
|
||||
}
|
||||
|
||||
getCurrentModel(): THREE.Object3D | null {
|
||||
return this.modelManager.currentModel
|
||||
}
|
||||
|
||||
setCameraState(state: CameraState): void {
|
||||
this.cameraManager.setCameraState(state)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
getCameraState(): CameraState {
|
||||
return this.cameraManager.getCameraState()
|
||||
}
|
||||
|
||||
setFOV(fov: number): void {
|
||||
this.cameraManager.setFOV(fov)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setCameraFromMatrices(
|
||||
extrinsics: readonly (readonly number[])[],
|
||||
intrinsics: readonly (readonly number[])[]
|
||||
@@ -553,19 +311,15 @@ class Load3d {
|
||||
this.setFOV(fovYDegrees)
|
||||
}
|
||||
|
||||
getCurrentModel(): THREE.Object3D | null {
|
||||
return this.modelManager.currentModel
|
||||
}
|
||||
|
||||
setMaterialMode(mode: MaterialMode): void {
|
||||
this.modelManager.setMaterialMode(mode)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic counter that ticks once per loadModel call, **before** any
|
||||
* await. Callers can capture this immediately after triggering a load and
|
||||
* later compare against `currentLoadGeneration` to verify their load is
|
||||
* still the latest one — useful when chaining post-load work
|
||||
* (e.g. applying camera matrices) through `whenLoadIdle()`, which would
|
||||
* otherwise wait for any newer queued load and apply stale state to it.
|
||||
*/
|
||||
get currentLoadGeneration(): number {
|
||||
return this._loadGeneration
|
||||
}
|
||||
@@ -606,8 +360,6 @@ class Load3d {
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
// First load always uses default framing; subsequent reloads preserve
|
||||
// the user's framing.
|
||||
const shouldRetainView = this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
@@ -623,7 +375,6 @@ class Load3d {
|
||||
|
||||
await this.loaderManager.loadModel(url, originalFileName, options)
|
||||
|
||||
// Auto-detect and setup animations if present
|
||||
if (this.modelManager.currentModel) {
|
||||
this.animationManager.setupModelAnimations(
|
||||
this.modelManager.currentModel,
|
||||
@@ -633,7 +384,6 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (savedCameraState) {
|
||||
// setupForModel runs during loadModel and clobbers the camera; restore on top.
|
||||
if (
|
||||
savedCameraState.cameraType !==
|
||||
this.cameraManager.getCurrentCameraType()
|
||||
@@ -674,11 +424,6 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setLightIntensity(intensity: number): void {
|
||||
this.lightingManager.setLightIntensity(intensity)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
async loadHDRI(url: string): Promise<void> {
|
||||
await this.hdriManager.loadHDRI(url)
|
||||
this.forceRender()
|
||||
@@ -706,73 +451,10 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
this.targetAspectRatio = width / height
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
addEventListener<T>(event: string, callback: EventCallback<T>): void {
|
||||
this.eventManager.addEventListener(event, callback)
|
||||
}
|
||||
|
||||
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
|
||||
this.eventManager.removeEventListener(event, callback)
|
||||
}
|
||||
|
||||
emitModelReady(): void {
|
||||
this.eventManager.emitEvent('modelReady', null)
|
||||
}
|
||||
|
||||
refreshViewport(): void {
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
handleResize(): void {
|
||||
const parentElement = this.renderer?.domElement?.parentElement
|
||||
|
||||
if (!parentElement) {
|
||||
console.warn('Parent element not found')
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = parentElement.clientWidth
|
||||
const containerHeight = parentElement.clientHeight
|
||||
|
||||
// Scale pixel density to match the graph zoom level so the 3D scene
|
||||
// renders at the correct resolution when the canvas is zoomed in or out.
|
||||
const zoomScale = this.getZoomScaleCallback?.() ?? 1
|
||||
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
|
||||
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
if (dims) {
|
||||
this.targetWidth = dims.width
|
||||
this.targetHeight = dims.height
|
||||
this.targetAspectRatio = dims.width / dims.height
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(width, height)
|
||||
this.sceneManager.handleResize(width, height)
|
||||
} else {
|
||||
// No aspect ratio constraint: use container dimensions directly
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(containerWidth, containerHeight)
|
||||
this.sceneManager.handleResize(containerWidth, containerHeight)
|
||||
}
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
captureScene(width: number, height: number): Promise<CaptureResult> {
|
||||
this.gizmoManager.removeFromScene()
|
||||
|
||||
@@ -818,7 +500,6 @@ class Load3d {
|
||||
this.recordingManager.clearRecording()
|
||||
}
|
||||
|
||||
// Animation methods
|
||||
public setAnimationSpeed(speed: number): void {
|
||||
this.animationManager.setAnimationSpeed(speed)
|
||||
}
|
||||
@@ -977,41 +658,15 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
this.disposeContextMenuGuard?.()
|
||||
this.disposeContextMenuGuard = null
|
||||
|
||||
this.renderer.forceContextLoss()
|
||||
const canvas = this.renderer.domElement
|
||||
const event = new Event('webglcontextlost', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
canvas.dispatchEvent(event)
|
||||
|
||||
this.renderLoop?.stop()
|
||||
this.renderLoop = null
|
||||
|
||||
this.sceneManager.dispose()
|
||||
this.cameraManager.dispose()
|
||||
this.controlsManager.dispose()
|
||||
this.lightingManager.dispose()
|
||||
protected override disposeManagers(): void {
|
||||
super.disposeManagers()
|
||||
this.hdriManager.dispose()
|
||||
this.viewHelperManager.dispose()
|
||||
this.loaderManager.dispose()
|
||||
this.modelManager.dispose()
|
||||
this.adapterRef.current = null
|
||||
this.recordingManager.dispose()
|
||||
this.animationManager.dispose()
|
||||
this.gizmoManager.dispose()
|
||||
|
||||
this.renderer.dispose()
|
||||
this.renderer.domElement.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
433
src/extensions/core/load3d/Viewport3d.test.ts
Normal file
433
src/extensions/core/load3d/Viewport3d.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
|
||||
|
||||
type CameraStub = {
|
||||
toggleCamera: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
setFOV: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
handleResize: ReturnType<typeof vi.fn>
|
||||
updateAspectRatio: ReturnType<typeof vi.fn>
|
||||
activeCamera: THREE.Camera
|
||||
}
|
||||
|
||||
type SceneStub = {
|
||||
captureScene: ReturnType<typeof vi.fn>
|
||||
setBackgroundColor: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
removeBackgroundImage: ReturnType<typeof vi.fn>
|
||||
toggleGrid: ReturnType<typeof vi.fn>
|
||||
setBackgroundRenderMode: ReturnType<typeof vi.fn>
|
||||
handleResize: ReturnType<typeof vi.fn>
|
||||
renderBackground: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
updateBackgroundSize: ReturnType<typeof vi.fn>
|
||||
backgroundTexture: unknown
|
||||
backgroundMesh: unknown
|
||||
scene: THREE.Scene
|
||||
}
|
||||
|
||||
function makeViewportInstance() {
|
||||
const cameraManager: CameraStub = {
|
||||
toggleCamera: vi.fn(),
|
||||
setupForModel: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
getCameraState: vi.fn(() => ({
|
||||
position: new THREE.Vector3(),
|
||||
target: new THREE.Vector3(),
|
||||
zoom: 1,
|
||||
cameraType: 'perspective' as const
|
||||
})),
|
||||
setCameraState: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective' as const),
|
||||
handleResize: vi.fn(),
|
||||
updateAspectRatio: vi.fn(),
|
||||
activeCamera: new THREE.PerspectiveCamera()
|
||||
}
|
||||
const sceneManager: SceneStub = {
|
||||
captureScene: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
removeBackgroundImage: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
renderBackground: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
updateBackgroundSize: vi.fn(),
|
||||
backgroundTexture: null,
|
||||
backgroundMesh: null,
|
||||
scene: new THREE.Scene()
|
||||
}
|
||||
const controlsManager = {
|
||||
updateCamera: vi.fn(),
|
||||
update: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
attach: vi.fn()
|
||||
}
|
||||
const lightingManager = {
|
||||
setLightIntensity: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const viewHelperManager = {
|
||||
recreateViewHelper: vi.fn(),
|
||||
update: vi.fn(),
|
||||
visibleViewHelper: vi.fn(),
|
||||
viewHelper: { render: vi.fn() },
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const eventManager = {
|
||||
emitEvent: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
|
||||
const viewport = Object.create(Viewport3d.prototype) as Viewport3d
|
||||
Object.assign(viewport, {
|
||||
cameraManager,
|
||||
sceneManager,
|
||||
controlsManager,
|
||||
lightingManager,
|
||||
viewHelperManager,
|
||||
eventManager,
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
})
|
||||
|
||||
return {
|
||||
viewport,
|
||||
cameraManager,
|
||||
sceneManager,
|
||||
controlsManager,
|
||||
lightingManager,
|
||||
viewHelperManager,
|
||||
eventManager,
|
||||
forceRender: viewport.forceRender as ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
describe('Viewport3d', () => {
|
||||
let ctx: ReturnType<typeof makeViewportInstance>
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = makeViewportInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('camera delegation (model-independent)', () => {
|
||||
it('toggleCamera updates controls and recreates view helper without touching model state', () => {
|
||||
ctx.viewport.toggleCamera('orthographic')
|
||||
|
||||
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
|
||||
'orthographic'
|
||||
)
|
||||
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isActive (no model concerns)', () => {
|
||||
it('returns false when no mouse activity is present', () => {
|
||||
Object.assign(ctx.viewport, {
|
||||
STATUS_MOUSE_ON_NODE: false,
|
||||
STATUS_MOUSE_ON_SCENE: false,
|
||||
STATUS_MOUSE_ON_VIEWER: false,
|
||||
INITIAL_RENDER_DONE: true
|
||||
})
|
||||
expect(ctx.viewport.isActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not consult recording or animation state — that is a Load3d concern', () => {
|
||||
Object.assign(ctx.viewport, {
|
||||
STATUS_MOUSE_ON_NODE: false,
|
||||
STATUS_MOUSE_ON_SCENE: false,
|
||||
STATUS_MOUSE_ON_VIEWER: false,
|
||||
INITIAL_RENDER_DONE: true
|
||||
})
|
||||
expect(() => ctx.viewport.isActive()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('manager accessors', () => {
|
||||
it('exposes managers through both fields and getters', () => {
|
||||
expect(ctx.viewport.cameraManager).toBe(ctx.cameraManager)
|
||||
expect(ctx.viewport.getCameraManager()).toBe(ctx.cameraManager)
|
||||
expect(ctx.viewport.sceneManager).toBe(ctx.sceneManager)
|
||||
expect(ctx.viewport.getSceneManager()).toBe(ctx.sceneManager)
|
||||
expect(ctx.viewport.controlsManager).toBe(ctx.controlsManager)
|
||||
expect(ctx.viewport.getControlsManager()).toBe(ctx.controlsManager)
|
||||
expect(ctx.viewport.lightingManager).toBe(ctx.lightingManager)
|
||||
expect(ctx.viewport.getLightingManager()).toBe(ctx.lightingManager)
|
||||
expect(ctx.viewport.viewHelperManager).toBe(ctx.viewHelperManager)
|
||||
expect(ctx.viewport.getViewHelperManager()).toBe(ctx.viewHelperManager)
|
||||
expect(ctx.viewport.eventManager).toBe(ctx.eventManager)
|
||||
expect(ctx.viewport.getEventManager()).toBe(ctx.eventManager)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POV swap (setExternalActiveCamera)', () => {
|
||||
it('getRenderCamera returns the orbit camera when no external camera is set', () => {
|
||||
expect(ctx.viewport.getRenderCamera()).toBe(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
})
|
||||
|
||||
it('getRenderCamera returns the external camera once installed', () => {
|
||||
const subjectCamera = new THREE.PerspectiveCamera()
|
||||
ctx.viewport.setExternalActiveCamera(subjectCamera)
|
||||
|
||||
expect(ctx.viewport.getRenderCamera()).toBe(subjectCamera)
|
||||
})
|
||||
|
||||
it('installing an external camera detaches controls and hides the view helper', () => {
|
||||
const subjectCamera = new THREE.PerspectiveCamera()
|
||||
ctx.viewport.setExternalActiveCamera(subjectCamera)
|
||||
|
||||
expect(ctx.controlsManager.detach).toHaveBeenCalledOnce()
|
||||
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
|
||||
false
|
||||
)
|
||||
expect(ctx.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clearing the external camera re-attaches controls and shows the view helper', () => {
|
||||
const subjectCamera = new THREE.PerspectiveCamera()
|
||||
ctx.viewport.setExternalActiveCamera(subjectCamera)
|
||||
ctx.controlsManager.detach.mockClear()
|
||||
ctx.controlsManager.attach.mockClear()
|
||||
ctx.viewHelperManager.visibleViewHelper.mockClear()
|
||||
|
||||
ctx.viewport.setExternalActiveCamera(null)
|
||||
|
||||
expect(ctx.controlsManager.attach).toHaveBeenCalledOnce()
|
||||
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(true)
|
||||
expect(ctx.viewport.getRenderCamera()).toBe(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
})
|
||||
|
||||
it('setting the same external camera twice is a no-op', () => {
|
||||
const subjectCamera = new THREE.PerspectiveCamera()
|
||||
ctx.viewport.setExternalActiveCamera(subjectCamera)
|
||||
ctx.controlsManager.detach.mockClear()
|
||||
|
||||
ctx.viewport.setExternalActiveCamera(subjectCamera)
|
||||
|
||||
expect(ctx.controlsManager.detach).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlay', () => {
|
||||
function makeOverlay() {
|
||||
return {
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
update: vi.fn(),
|
||||
onActiveCameraChange: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
it('setOverlay attaches to the scene and notifies of the current render camera', () => {
|
||||
const overlay = makeOverlay()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
|
||||
expect(overlay.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
|
||||
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.viewport.getOverlay()).toBe(overlay)
|
||||
})
|
||||
|
||||
it('replacing an overlay detaches and disposes the prior one', () => {
|
||||
const first = makeOverlay()
|
||||
const second = makeOverlay()
|
||||
ctx.viewport.setOverlay(first)
|
||||
ctx.viewport.setOverlay(second)
|
||||
|
||||
expect(first.detach).toHaveBeenCalledOnce()
|
||||
expect(first.dispose).toHaveBeenCalledOnce()
|
||||
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
|
||||
expect(ctx.viewport.getOverlay()).toBe(second)
|
||||
})
|
||||
|
||||
it('removeOverlay detaches and disposes the installed overlay', () => {
|
||||
const overlay = makeOverlay()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
|
||||
ctx.viewport.removeOverlay()
|
||||
|
||||
expect(overlay.detach).toHaveBeenCalledOnce()
|
||||
expect(overlay.dispose).toHaveBeenCalledOnce()
|
||||
expect(ctx.viewport.getOverlay()).toBeNull()
|
||||
})
|
||||
|
||||
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
|
||||
const overlay = makeOverlay()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
|
||||
const tick = (
|
||||
ctx.viewport as unknown as {
|
||||
tickPerFrame(delta: number): void
|
||||
}
|
||||
).tickPerFrame.bind(ctx.viewport)
|
||||
tick(0.016)
|
||||
|
||||
expect(overlay.update).toHaveBeenCalledWith(0.016)
|
||||
expect(ctx.viewHelperManager.update).toHaveBeenCalledWith(0.016)
|
||||
expect(ctx.controlsManager.update).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('toggleCamera notifies the overlay with the new orbit camera (when no POV)', () => {
|
||||
const overlay = makeOverlay()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
overlay.onActiveCameraChange.mockClear()
|
||||
|
||||
ctx.viewport.toggleCamera('orthographic')
|
||||
|
||||
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
})
|
||||
|
||||
it('toggleCamera does NOT notify the overlay while a POV camera is active', () => {
|
||||
const overlay = makeOverlay()
|
||||
const subjectCamera = new THREE.PerspectiveCamera()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
ctx.viewport.setExternalActiveCamera(subjectCamera)
|
||||
overlay.onActiveCameraChange.mockClear()
|
||||
|
||||
ctx.viewport.toggleCamera('orthographic')
|
||||
|
||||
expect(overlay.onActiveCameraChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setExternalActiveCamera notifies the overlay with the new render camera', () => {
|
||||
const overlay = makeOverlay()
|
||||
const subjectCamera = new THREE.PerspectiveCamera()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
overlay.onActiveCameraChange.mockClear()
|
||||
|
||||
ctx.viewport.setExternalActiveCamera(subjectCamera)
|
||||
|
||||
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(subjectCamera)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTargetSize guards', () => {
|
||||
function applyTargetSize(width: number, height: number): void {
|
||||
;(
|
||||
ctx.viewport as unknown as {
|
||||
applyTargetSize(w: number, h: number): void
|
||||
}
|
||||
).applyTargetSize(width, height)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(ctx.viewport, {
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
targetAspectRatio: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('writes width / height / aspect when both inputs are positive finite', () => {
|
||||
applyTargetSize(800, 400)
|
||||
|
||||
expect(ctx.viewport.targetWidth).toBe(800)
|
||||
expect(ctx.viewport.targetHeight).toBe(400)
|
||||
expect(ctx.viewport.targetAspectRatio).toBe(2)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['zero width', 0, 100],
|
||||
['zero height', 100, 0],
|
||||
['negative width', -100, 100],
|
||||
['negative height', 100, -100],
|
||||
['NaN width', Number.NaN, 100],
|
||||
['Infinity height', 100, Number.POSITIVE_INFINITY]
|
||||
] as const)('rejects %s without touching prior state', ([, w, h]) => {
|
||||
Object.assign(ctx.viewport, {
|
||||
targetWidth: 800,
|
||||
targetHeight: 400,
|
||||
targetAspectRatio: 2
|
||||
})
|
||||
|
||||
applyTargetSize(w, h)
|
||||
|
||||
expect(ctx.viewport.targetWidth).toBe(800)
|
||||
expect(ctx.viewport.targetHeight).toBe(400)
|
||||
expect(ctx.viewport.targetAspectRatio).toBe(2)
|
||||
})
|
||||
|
||||
it('setTargetSize routes through the guard', () => {
|
||||
Object.assign(ctx.viewport, {
|
||||
targetWidth: 800,
|
||||
targetHeight: 400,
|
||||
targetAspectRatio: 2
|
||||
})
|
||||
|
||||
ctx.viewport.setTargetSize(0, 0)
|
||||
|
||||
expect(ctx.viewport.targetAspectRatio).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('start / remove lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
Object.assign(ctx.viewport, {
|
||||
hasStarted: false,
|
||||
initialRenderTimer: null,
|
||||
startAnimation: vi.fn(),
|
||||
renderLoop: { stop: vi.fn() },
|
||||
resizeObserver: null,
|
||||
disposeContextMenuGuard: null,
|
||||
renderer: {
|
||||
forceContextLoss: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
domElement: Object.assign(document.createElement('canvas'), {
|
||||
remove: vi.fn()
|
||||
})
|
||||
},
|
||||
disposeManagers: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('start schedules a deferred forceRender and remove clears it before the timer fires', () => {
|
||||
ctx.viewport.start()
|
||||
ctx.forceRender.mockClear()
|
||||
|
||||
ctx.viewport.remove()
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(ctx.forceRender).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the deferred forceRender does fire when remove is not called', () => {
|
||||
ctx.viewport.start()
|
||||
ctx.forceRender.mockClear()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
416
src/extensions/core/load3d/Viewport3d.ts
Normal file
416
src/extensions/core/load3d/Viewport3d.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
|
||||
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
import type { EventManager } from './EventManager'
|
||||
import type { LightingManager } from './LightingManager'
|
||||
import type { SceneManager } from './SceneManager'
|
||||
import type { ViewHelperManager } from './ViewHelperManager'
|
||||
import type {
|
||||
CameraState,
|
||||
EventCallback,
|
||||
Load3DOptions,
|
||||
SceneOverlay
|
||||
} from './interfaces'
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
|
||||
export type Viewport3dDeps = {
|
||||
renderer: THREE.WebGLRenderer
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
}
|
||||
|
||||
export class Viewport3d extends WebGLViewport {
|
||||
protected clock: THREE.Clock
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
|
||||
STATUS_MOUSE_ON_NODE: boolean
|
||||
STATUS_MOUSE_ON_SCENE: boolean
|
||||
STATUS_MOUSE_ON_VIEWER: boolean
|
||||
INITIAL_RENDER_DONE: boolean = false
|
||||
|
||||
targetWidth: number = 0
|
||||
targetHeight: number = 0
|
||||
targetAspectRatio: number = 1
|
||||
isViewerMode: boolean = false
|
||||
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private externalActiveCamera: THREE.Camera | null = null
|
||||
private overlay: SceneOverlay | null = null
|
||||
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
deps: Viewport3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
super(deps.renderer)
|
||||
this.clock = new THREE.Clock()
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
this.getDimensionsCallback = options.getDimensions
|
||||
this.getZoomScaleCallback = options.getZoomScale
|
||||
|
||||
if (options.width !== undefined && options.height !== undefined) {
|
||||
this.applyTargetSize(options.width, options.height)
|
||||
}
|
||||
|
||||
this.eventManager = deps.eventManager
|
||||
this.sceneManager = deps.sceneManager
|
||||
this.cameraManager = deps.cameraManager
|
||||
this.controlsManager = deps.controlsManager
|
||||
this.lightingManager = deps.lightingManager
|
||||
this.viewHelperManager = deps.viewHelperManager
|
||||
|
||||
this.sceneManager.init()
|
||||
this.cameraManager.init()
|
||||
this.controlsManager.init()
|
||||
this.lightingManager.init()
|
||||
|
||||
this.viewHelperManager.createViewHelper(container)
|
||||
this.viewHelperManager.init()
|
||||
|
||||
this.STATUS_MOUSE_ON_NODE = false
|
||||
this.STATUS_MOUSE_ON_SCENE = false
|
||||
this.STATUS_MOUSE_ON_VIEWER = false
|
||||
|
||||
this.initContextMenu()
|
||||
this.observeResize(container, () => this.handleResize())
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.hasStarted) return
|
||||
this.hasStarted = true
|
||||
this.handleResize()
|
||||
this.startAnimation()
|
||||
this.initialRenderTimer = setTimeout(() => {
|
||||
this.initialRenderTimer = null
|
||||
this.forceRender()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private hasStarted: boolean = false
|
||||
|
||||
private applyTargetSize(width: number, height: number): void {
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return
|
||||
if (width <= 0 || height <= 0) return
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
this.targetAspectRatio = width / height
|
||||
}
|
||||
|
||||
private initContextMenu(): void {
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
(event) => this.onContextMenuCallback?.(event),
|
||||
{ isDisabled: () => this.isViewerMode }
|
||||
)
|
||||
}
|
||||
|
||||
getEventManager(): EventManager {
|
||||
return this.eventManager
|
||||
}
|
||||
getSceneManager(): SceneManager {
|
||||
return this.sceneManager
|
||||
}
|
||||
getCameraManager(): CameraManager {
|
||||
return this.cameraManager
|
||||
}
|
||||
getControlsManager(): ControlsManager {
|
||||
return this.controlsManager
|
||||
}
|
||||
getLightingManager(): LightingManager {
|
||||
return this.lightingManager
|
||||
}
|
||||
getViewHelperManager(): ViewHelperManager {
|
||||
return this.viewHelperManager
|
||||
}
|
||||
|
||||
getTargetSize(): { width: number; height: number } {
|
||||
return {
|
||||
width: this.targetWidth,
|
||||
height: this.targetHeight
|
||||
}
|
||||
}
|
||||
|
||||
protected shouldMaintainAspectRatio(): boolean {
|
||||
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
|
||||
}
|
||||
|
||||
forceRender(): void {
|
||||
const delta = this.clock.getDelta()
|
||||
this.tickPerFrame(delta)
|
||||
|
||||
this.renderMainScene()
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
|
||||
this.INITIAL_RENDER_DONE = true
|
||||
}
|
||||
|
||||
protected tickPerFrame(delta: number): void {
|
||||
this.overlay?.update?.(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
}
|
||||
|
||||
getRenderCamera(): THREE.Camera {
|
||||
return this.externalActiveCamera ?? this.cameraManager.activeCamera
|
||||
}
|
||||
|
||||
setExternalActiveCamera(camera: THREE.Camera | null): void {
|
||||
if (this.externalActiveCamera === camera) return
|
||||
this.externalActiveCamera = camera
|
||||
if (camera) {
|
||||
this.controlsManager.detach()
|
||||
this.viewHelperManager.visibleViewHelper(false)
|
||||
} else {
|
||||
this.controlsManager.attach()
|
||||
this.viewHelperManager.visibleViewHelper(true)
|
||||
}
|
||||
this.overlay?.onActiveCameraChange?.(this.getRenderCamera())
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setOverlay(overlay: SceneOverlay): void {
|
||||
if (this.overlay === overlay) return
|
||||
if (this.overlay) {
|
||||
this.overlay.detach()
|
||||
this.overlay.dispose()
|
||||
}
|
||||
this.overlay = overlay
|
||||
overlay.attach(this.sceneManager.scene)
|
||||
overlay.onActiveCameraChange?.(this.getRenderCamera())
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
removeOverlay(): void {
|
||||
if (!this.overlay) return
|
||||
this.overlay.detach()
|
||||
this.overlay.dispose()
|
||||
this.overlay = null
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
getOverlay(): SceneOverlay | null {
|
||||
return this.overlay
|
||||
}
|
||||
|
||||
renderMainScene(): void {
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
if (dims) {
|
||||
this.applyTargetSize(dims.width, dims.height)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
this.renderer.setClearColor(0x0a0a0a)
|
||||
this.renderer.clear()
|
||||
|
||||
this.renderer.setViewport(offsetX, offsetY, width, height)
|
||||
this.renderer.setScissor(offsetX, offsetY, width, height)
|
||||
|
||||
this.cameraManager.updateAspectRatio(width / height)
|
||||
} else {
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
}
|
||||
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
|
||||
}
|
||||
|
||||
resetViewport(): void {
|
||||
const width = this.renderer.domElement.clientWidth
|
||||
const height = this.renderer.domElement.clientHeight
|
||||
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
protected startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
const delta = this.clock.getDelta()
|
||||
this.tickPerFrame(delta)
|
||||
this.renderMainScene()
|
||||
this.resetViewport()
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
},
|
||||
isActive: () => this.isActive()
|
||||
})
|
||||
}
|
||||
|
||||
updateStatusMouseOnNode(onNode: boolean): void {
|
||||
this.STATUS_MOUSE_ON_NODE = onNode
|
||||
}
|
||||
|
||||
updateStatusMouseOnScene(onScene: boolean): void {
|
||||
this.STATUS_MOUSE_ON_SCENE = onScene
|
||||
}
|
||||
|
||||
updateStatusMouseOnViewer(onViewer: boolean): void {
|
||||
this.STATUS_MOUSE_ON_VIEWER = onViewer
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return isLoad3dActive({
|
||||
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
|
||||
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
|
||||
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
|
||||
recording: false,
|
||||
initialRenderDone: this.INITIAL_RENDER_DONE,
|
||||
animationPlaying: false
|
||||
})
|
||||
}
|
||||
|
||||
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
|
||||
this.cameraManager.toggleCamera(cameraType)
|
||||
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
|
||||
this.onActiveCameraChanged()
|
||||
this.viewHelperManager.recreateViewHelper()
|
||||
if (!this.externalActiveCamera) {
|
||||
this.overlay?.onActiveCameraChange?.(this.cameraManager.activeCamera)
|
||||
}
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
protected onActiveCameraChanged(): void {}
|
||||
|
||||
getCurrentCameraType(): 'perspective' | 'orthographic' {
|
||||
return this.cameraManager.getCurrentCameraType()
|
||||
}
|
||||
|
||||
setCameraState(state: CameraState): void {
|
||||
this.cameraManager.setCameraState(state)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
getCameraState(): CameraState {
|
||||
return this.cameraManager.getCameraState()
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
this.applyTargetSize(width, height)
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
addEventListener<T>(event: string, callback: EventCallback<T>): void {
|
||||
this.eventManager.addEventListener(event, callback)
|
||||
}
|
||||
|
||||
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
|
||||
this.eventManager.removeEventListener(event, callback)
|
||||
}
|
||||
|
||||
refreshViewport(): void {
|
||||
this.handleResize()
|
||||
}
|
||||
|
||||
handleResize(): void {
|
||||
const parentElement = this.renderer?.domElement?.parentElement
|
||||
|
||||
if (!parentElement) {
|
||||
console.warn('Parent element not found')
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = parentElement.clientWidth
|
||||
const containerHeight = parentElement.clientHeight
|
||||
|
||||
const zoomScale = this.getZoomScaleCallback?.() ?? 1
|
||||
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
|
||||
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
if (dims) {
|
||||
this.applyTargetSize(dims.width, dims.height)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(width, height)
|
||||
this.sceneManager.handleResize(width, height)
|
||||
} else {
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(containerWidth, containerHeight)
|
||||
this.sceneManager.handleResize(containerWidth, containerHeight)
|
||||
}
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
if (this.initialRenderTimer) {
|
||||
clearTimeout(this.initialRenderTimer)
|
||||
this.initialRenderTimer = null
|
||||
}
|
||||
|
||||
this.disposeContextMenuGuard?.()
|
||||
this.disposeContextMenuGuard = null
|
||||
|
||||
this.renderLoop?.stop()
|
||||
this.renderLoop = null
|
||||
|
||||
this.disposeManagers()
|
||||
|
||||
this.disposeRenderer()
|
||||
}
|
||||
|
||||
protected disposeManagers(): void {
|
||||
if (this.overlay) {
|
||||
this.overlay.detach()
|
||||
this.overlay.dispose()
|
||||
this.overlay = null
|
||||
}
|
||||
this.sceneManager.dispose()
|
||||
this.cameraManager.dispose()
|
||||
this.controlsManager.dispose()
|
||||
this.lightingManager.dispose()
|
||||
this.viewHelperManager.dispose()
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const LOAD3D_NONE_MODEL = 'none'
|
||||
|
||||
export const DIRECT_EXPORT_FORMATS = new Set(['ply', 'spz', 'splat', 'ksplat'])
|
||||
|
||||
export interface ExportFormatOption {
|
||||
interface ExportFormatOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
@@ -244,6 +244,14 @@ export interface LoadModelOptions {
|
||||
silentOnNotFound?: boolean
|
||||
}
|
||||
|
||||
export interface SceneOverlay {
|
||||
attach(scene: THREE.Scene): void
|
||||
detach(): void
|
||||
update?(deltaSeconds: number): void
|
||||
onActiveCameraChange?(camera: THREE.Camera): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export interface LoaderManagerInterface {
|
||||
init(): void
|
||||
dispose(): void
|
||||
|
||||
@@ -119,6 +119,23 @@ describe('load3dLazy', () => {
|
||||
expect(spec.upload_subfolder).toBe('3d')
|
||||
})
|
||||
|
||||
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3DAdvanced', {
|
||||
input: {
|
||||
required: { model_file: ['STRING', {}] }
|
||||
}
|
||||
} as Partial<ComfyNodeDef>)
|
||||
|
||||
await hook({} as typeof LGraphNode, nodeData)
|
||||
|
||||
const spec = (
|
||||
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
|
||||
)[1]
|
||||
expect(spec.mesh_upload).toBe(true)
|
||||
expect(spec.upload_subfolder).toBe('3d')
|
||||
})
|
||||
|
||||
it('does not throw when a Load3D node has no model_file widget spec', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3D', {
|
||||
|
||||
@@ -61,18 +61,12 @@ useExtensionService().registerExtension({
|
||||
if (isLoad3dNode(nodeData.name)) {
|
||||
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
|
||||
// Load3D's model_file as a mesh upload widget without hardcoding.
|
||||
if (nodeData.name === 'Load3D') {
|
||||
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
|
||||
const modelFile = nodeData.input?.required?.model_file
|
||||
if (modelFile?.[1]) {
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = '3d'
|
||||
}
|
||||
} else if (nodeData.name === 'Load3DAdvanced') {
|
||||
const modelFile = nodeData.input?.required?.model_file
|
||||
if (modelFile?.[1]) {
|
||||
modelFile[1].mesh_upload = true
|
||||
modelFile[1].upload_subfolder = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
|
||||
101
src/extensions/core/saveImageExtraOutput.test.ts
Normal file
101
src/extensions/core/saveImageExtraOutput.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const { app } = vi.hoisted(() => ({
|
||||
app: {
|
||||
registerExtension: vi.fn(),
|
||||
graph: undefined as unknown as LGraph
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app }))
|
||||
|
||||
type BeforeRegisterNodeDef = NonNullable<
|
||||
ComfyExtension['beforeRegisterNodeDef']
|
||||
>
|
||||
|
||||
interface FilenamePrefixWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
serializeValue?: () => string
|
||||
}
|
||||
|
||||
async function loadExtension(): Promise<ComfyExtension> {
|
||||
vi.resetModules()
|
||||
app.registerExtension.mockClear()
|
||||
await import('./saveImageExtraOutput')
|
||||
return app.registerExtension.mock.calls[0][0] as ComfyExtension
|
||||
}
|
||||
|
||||
async function createNodeWithFilenamePrefix(
|
||||
nodeName: string,
|
||||
prefix: string
|
||||
): Promise<FilenamePrefixWidget> {
|
||||
const ext = await loadExtension()
|
||||
|
||||
const nodeType = {
|
||||
prototype: {}
|
||||
} as unknown as Parameters<BeforeRegisterNodeDef>[0]
|
||||
const nodeData = { name: nodeName } as ComfyNodeDef
|
||||
|
||||
await ext.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<BeforeRegisterNodeDef>[2]
|
||||
)
|
||||
|
||||
const widget: FilenamePrefixWidget = {
|
||||
name: 'filename_prefix',
|
||||
value: prefix
|
||||
}
|
||||
const node = { widgets: [widget] }
|
||||
const proto = nodeType.prototype as { onNodeCreated?: () => void }
|
||||
proto.onNodeCreated!.call(node)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
describe('Comfy.SaveImageExtraOutput', () => {
|
||||
beforeEach(() => {
|
||||
const graph = new LGraph()
|
||||
graph.add({
|
||||
properties: { 'Node name for S&R': 'Sampler' },
|
||||
widgets: [{ name: 'seed', value: 12345 }]
|
||||
} as unknown as LGraphNode)
|
||||
app.graph = graph
|
||||
})
|
||||
|
||||
it.each([
|
||||
'SaveImage',
|
||||
'SaveImageAdvanced',
|
||||
'SaveSVGNode',
|
||||
'SaveVideo',
|
||||
'SaveAnimatedWEBP',
|
||||
'SaveWEBM',
|
||||
'SaveAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
'VAESave',
|
||||
'ModelSave',
|
||||
'LoraSave',
|
||||
'SaveLatent'
|
||||
])(
|
||||
'resolves text replacements in the filename_prefix of %s on serialize',
|
||||
async (nodeName) => {
|
||||
const widget = await createNodeWithFilenamePrefix(
|
||||
nodeName,
|
||||
'ComfyUI_%Sampler.seed%'
|
||||
)
|
||||
|
||||
expect(widget.serializeValue!()).toBe('ComfyUI_12345')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -6,10 +6,15 @@ import { app } from '../../scripts/app'
|
||||
|
||||
const saveNodeTypes = new Set([
|
||||
'SaveImage',
|
||||
'SaveImageAdvanced',
|
||||
'SaveSVGNode',
|
||||
'SaveVideo',
|
||||
'SaveAnimatedWEBP',
|
||||
'SaveWEBM',
|
||||
'SaveAudio',
|
||||
'SaveAudioMP3',
|
||||
'SaveAudioOpus',
|
||||
'SaveAudioAdvanced',
|
||||
'SaveGLB',
|
||||
'SaveAnimatedPNG',
|
||||
'CLIPSave',
|
||||
|
||||
@@ -8931,71 +8931,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all nodes that are children of groups in the selection
|
||||
*/
|
||||
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
|
||||
const nodesInGroups = new Set<LGraphNode>()
|
||||
for (const item of items) {
|
||||
if (item instanceof LGraphGroup) {
|
||||
for (const child of item._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
nodesInGroups.add(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodesInGroups
|
||||
}
|
||||
|
||||
/**
|
||||
* Move group children (both nodes and non-nodes)
|
||||
*/
|
||||
private moveGroupChildren(
|
||||
group: LGraphGroup,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
|
||||
): void {
|
||||
for (const child of group._children) {
|
||||
if (child instanceof LGraphNode) {
|
||||
const node = child as LGraphNode
|
||||
nodesToMove.push({
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else if (!(child instanceof LGraphGroup)) {
|
||||
// Non-node, non-group children (reroutes, etc.)
|
||||
// Skip groups here - they're already in allItems and will be
|
||||
// processed in the main loop of moveChildNodesInGroupVueMode
|
||||
child.move(deltaX, deltaY, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveChildNodesInGroupVueMode(
|
||||
allItems: Set<Positionable>,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
) {
|
||||
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
|
||||
const nodesToMove: NewNodePosition[] = []
|
||||
|
||||
// First, collect all the moves we need to make
|
||||
for (const item of allItems) {
|
||||
const isNode = item instanceof LGraphNode
|
||||
if (isNode) {
|
||||
const node = item as LGraphNode
|
||||
if (nodesInMovingGroups.has(node)) {
|
||||
continue
|
||||
}
|
||||
if (item instanceof LGraphNode) {
|
||||
nodesToMove.push({
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
node: item,
|
||||
newPos: this.calculateNewPosition(item, deltaX, deltaY)
|
||||
})
|
||||
} else if (item instanceof LGraphGroup) {
|
||||
item.move(deltaX, deltaY, true)
|
||||
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
|
||||
} else {
|
||||
// Other items (reroutes, etc.)
|
||||
item.move(deltaX, deltaY, true)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -141,6 +142,8 @@ export type IWidget =
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
| IBoundingBoxesWidget
|
||||
| IColorsWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -343,6 +346,19 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IBoundingBoxesWidget extends IBaseWidget<
|
||||
BoundingBox[],
|
||||
'boundingboxes'
|
||||
> {
|
||||
type: 'boundingboxes'
|
||||
value: BoundingBox[]
|
||||
}
|
||||
|
||||
export interface IColorsWidget extends IBaseWidget<string[], 'colors'> {
|
||||
type: 'colors'
|
||||
value: string[]
|
||||
}
|
||||
|
||||
export interface RangeValue {
|
||||
min: number
|
||||
max: number
|
||||
|
||||
42
src/lib/litegraph/src/widgets/BoundingBoxesWidget.test.ts
Normal file
42
src/lib/litegraph/src/widgets/BoundingBoxesWidget.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
|
||||
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
|
||||
|
||||
function fakeCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: ''
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
describe('BoundingBoxesWidget', () => {
|
||||
it('has the boundingboxes type and draws the Vue-only placeholder', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const widget = new BoundingBoxesWidget(
|
||||
{
|
||||
type: 'boundingboxes',
|
||||
name: 'editor_state',
|
||||
value: [],
|
||||
options: {},
|
||||
y: 0
|
||||
},
|
||||
node
|
||||
)
|
||||
expect(widget.type).toBe('boundingboxes')
|
||||
const ctx = fakeCtx()
|
||||
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
|
||||
expect(ctx.fillText).toHaveBeenCalled()
|
||||
expect(() => widget.onClick({} as never)).not.toThrow()
|
||||
})
|
||||
})
|
||||
16
src/lib/litegraph/src/widgets/BoundingBoxesWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/BoundingBoxesWidget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IBoundingBoxesWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class BoundingBoxesWidget
|
||||
extends BaseWidget<IBoundingBoxesWidget>
|
||||
implements IBoundingBoxesWidget
|
||||
{
|
||||
override type = 'boundingboxes' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Bounding Boxes')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
36
src/lib/litegraph/src/widgets/ColorsWidget.test.ts
Normal file
36
src/lib/litegraph/src/widgets/ColorsWidget.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
|
||||
import { ColorsWidget } from './ColorsWidget'
|
||||
|
||||
function fakeCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: ''
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
describe('ColorsWidget', () => {
|
||||
it('has the colors type and draws the Vue-only placeholder', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const widget = new ColorsWidget(
|
||||
{ type: 'colors', name: 'palette', value: [], options: {}, y: 0 },
|
||||
node
|
||||
)
|
||||
expect(widget.type).toBe('colors')
|
||||
const ctx = fakeCtx()
|
||||
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
|
||||
expect(ctx.fillText).toHaveBeenCalled()
|
||||
expect(() => widget.onClick({} as never)).not.toThrow()
|
||||
})
|
||||
})
|
||||
16
src/lib/litegraph/src/widgets/ColorsWidget.ts
Normal file
16
src/lib/litegraph/src/widgets/ColorsWidget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IColorsWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class ColorsWidget
|
||||
extends BaseWidget<IColorsWidget>
|
||||
implements IColorsWidget
|
||||
{
|
||||
override type = 'colors' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Colors')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { FileUploadWidget } from './FileUploadWidget'
|
||||
import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
import { ImageCompareWidget } from './ImageCompareWidget'
|
||||
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
|
||||
import { ColorsWidget } from './ColorsWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { RangeWidget } from './RangeWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
@@ -62,6 +64,8 @@ export type WidgetTypeMap = {
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
boundingboxes: BoundingBoxesWidget
|
||||
colors: ColorsWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -144,6 +148,10 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
case 'range':
|
||||
return toClass(RangeWidget, narrowedWidget, node)
|
||||
case 'boundingboxes':
|
||||
return toClass(BoundingBoxesWidget, narrowedWidget, node)
|
||||
case 'colors':
|
||||
return toClass(ColorsWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
@@ -387,6 +387,35 @@
|
||||
"collapseAll": "Collapse all",
|
||||
"expandAll": "Expand all"
|
||||
},
|
||||
"hdrViewer": {
|
||||
"title": "HDR Viewer",
|
||||
"openInHdrViewer": "Open in HDR Viewer",
|
||||
"hdrImage": "HDR image",
|
||||
"failedToLoad": "Failed to load HDR image",
|
||||
"exposure": "Exposure",
|
||||
"normalizeExposure": "Auto exposure",
|
||||
"channel": "Channel",
|
||||
"channels": {
|
||||
"rgb": "RGB",
|
||||
"r": "R",
|
||||
"g": "G",
|
||||
"b": "B",
|
||||
"a": "Alpha",
|
||||
"luminance": "Luminance"
|
||||
},
|
||||
"sourceGamut": "Source gamut",
|
||||
"dither": "Dither",
|
||||
"clipWarnings": "Clip warnings",
|
||||
"fitView": "Fit",
|
||||
"histogram": "Histogram",
|
||||
"resolution": "Resolution",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"mean": "Mean",
|
||||
"stdDev": "Std dev",
|
||||
"nan": "NaN",
|
||||
"inf": "Inf"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Nodes Manager",
|
||||
"nodePackInfo": "Node Pack Info",
|
||||
@@ -2091,6 +2120,21 @@
|
||||
"monotone_cubic": "Smooth",
|
||||
"linear": "Linear"
|
||||
},
|
||||
"boundingBoxes": {
|
||||
"clearAll": "Clear all",
|
||||
"clickRegionToEdit": "Click a region to edit it.",
|
||||
"typeObj": "obj",
|
||||
"typeText": "text",
|
||||
"textLabel": "Text",
|
||||
"descLabel": "description",
|
||||
"textPlaceholder": "text to render (verbatim)",
|
||||
"descPlaceholder": "description of this region",
|
||||
"colors": "color_palette"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "Add a color",
|
||||
"swatchTitle": "Click edit · drag reorder · right-click remove"
|
||||
},
|
||||
"toastMessages": {
|
||||
"nothingToQueue": "Nothing to queue",
|
||||
"pleaseSelectOutputNodes": "Please select output nodes",
|
||||
@@ -3091,6 +3135,13 @@
|
||||
"loadingModels": "Loading {type}...",
|
||||
"maxFileSize": "Max file size: {size}",
|
||||
"maxFileSizeValue": "1 GB",
|
||||
"missingModelImportTypeLocked": "Locked to {type} for this missing model",
|
||||
"missingModelImportTypeMismatchAlreadyImported": "This file is already imported as {actual}.",
|
||||
"missingModelImportTypeMismatchNextAction": "Try importing a different {required} model that this node can use.",
|
||||
"missingModelImportTypeMismatchRequired": "This node requires {required}, so this import cannot resolve the missing model.",
|
||||
"missingModelImportTypeMismatchTitle": "This model cannot resolve the missing model.",
|
||||
"missingModelImportUnknownType": "another model type",
|
||||
"missingModelImportWillReplace": "This import will replace {model} in:",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
@@ -3618,6 +3669,10 @@
|
||||
"hideAdvancedShort": "Hide advanced",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"errorsDetected": "Error detected | Errors detected",
|
||||
"resolveBeforeRun": "Resolve before running the workflow",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
@@ -3643,32 +3698,17 @@
|
||||
"expand": "Expand"
|
||||
},
|
||||
"missingModels": {
|
||||
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
|
||||
"or": "OR",
|
||||
"useFromLibrary": "Use from Library",
|
||||
"usingFromLibrary": "Using from Library",
|
||||
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
|
||||
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"imported": "Imported",
|
||||
"importFailed": "Import failed",
|
||||
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
|
||||
"importAnyway": "Import Anyway",
|
||||
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
|
||||
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
|
||||
"customNodeDownloadDisabled": "Nodes that reference the models below do not support imported models. Open the node to choose a supported built-in model, or replace it with a standard node that supports imported models.",
|
||||
"importNotSupported": "Import Not Supported",
|
||||
"copyModelName": "Copy model name",
|
||||
"copyUrl": "Copy URL",
|
||||
"confirmSelection": "Confirm selection",
|
||||
"locateNode": "Locate node on canvas",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"clearUrl": "Clear URL",
|
||||
"expandNodes": "Show referencing nodes",
|
||||
"collapseNodes": "Hide referencing nodes",
|
||||
"unknownCategory": "Unknown",
|
||||
"missingModelsTitle": "Missing Models",
|
||||
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
|
||||
"downloadAll": "Download all",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing missing models.",
|
||||
@@ -3679,7 +3719,6 @@
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERROR | {count} ERRORS",
|
||||
"multipleErrorCount": "{count} error found | {count} errors found",
|
||||
"multipleErrorsMessage": "Resolve them before running the workflow.",
|
||||
"viewDetails": "View details",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
|
||||
import UploadModelConfirmation from './UploadModelConfirmation.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
escapeParameter: true
|
||||
})
|
||||
|
||||
const SingleSelectStub = {
|
||||
name: 'SingleSelect',
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
modelValue: String
|
||||
},
|
||||
template:
|
||||
'<button type="button" :disabled="disabled">{{ modelValue }}</button>'
|
||||
}
|
||||
|
||||
describe('UploadModelConfirmation', () => {
|
||||
it('shows missing-model replacement context and locks the model type', () => {
|
||||
const uploadContext: UploadModelDialogContext = {
|
||||
kind: 'missing-model-resolution',
|
||||
missingModelName: 'segm/person_yolov8m-seg.pt',
|
||||
requiredModelType: 'Ultralytics/bbox',
|
||||
replacementTargets: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeLabel: 'Checkpoint Loader',
|
||||
widgetName: 'ckpt_name'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
render(UploadModelConfirmation, {
|
||||
props: {
|
||||
modelValue: 'Ultralytics/bbox',
|
||||
metadata: {
|
||||
content_length: 100,
|
||||
final_url: 'https://civitai.com/models/123',
|
||||
filename: 'replacement.safetensors'
|
||||
},
|
||||
uploadContext,
|
||||
'onUpdate:modelValue': () => {}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SingleSelect: SingleSelectStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('segm/person_yolov8m-seg.pt')).toBeInTheDocument()
|
||||
expect(screen.getByText('Checkpoint Loader')).toBeInTheDocument()
|
||||
expect(screen.getByText('- ckpt_name')).toBeInTheDocument()
|
||||
const modelTypeSelect = screen.getByRole('button', {
|
||||
name: 'Ultralytics/bbox'
|
||||
})
|
||||
|
||||
expect(modelTypeSelect).toBeDisabled()
|
||||
expect(
|
||||
screen.getByText((_content, element) => {
|
||||
return (
|
||||
element?.textContent ===
|
||||
'Locked to Ultralytics/bbox for this missing model'
|
||||
)
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -22,16 +22,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isMissingModelResolution"
|
||||
class="flex flex-col gap-2 rounded-lg bg-secondary-background px-4 py-3"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="assetBrowser.missingModelImportWillReplace"
|
||||
tag="p"
|
||||
class="m-0 text-base-foreground"
|
||||
>
|
||||
<template #model>
|
||||
<span>{{ missingModelName }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="target in replacementTargets"
|
||||
:key="`${target.nodeId}:${target.widgetName}`"
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span class="min-w-0 truncate text-muted-foreground">
|
||||
{{ target.nodeLabel }}
|
||||
</span>
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
- {{ target.widgetName }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label>
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('assetBrowser.notSureLeaveAsIs') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<label>
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-question-mark] text-muted-foreground"
|
||||
/>
|
||||
<span v-if="!isMissingModelResolution" class="text-muted-foreground">
|
||||
{{ $t('assetBrowser.notSureLeaveAsIs') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SingleSelect
|
||||
v-model="modelValue"
|
||||
@@ -41,23 +75,37 @@
|
||||
: $t('assetBrowser.modelTypeSelectorPlaceholder')
|
||||
"
|
||||
:options="modelTypes"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || isMissingModelResolution"
|
||||
:content-style="selectContentStyle"
|
||||
data-attr="upload-model-step2-type-selector"
|
||||
/>
|
||||
<i18n-t
|
||||
v-if="isMissingModelResolution"
|
||||
keypath="assetBrowser.missingModelImportTypeLocked"
|
||||
tag="span"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<template #type>
|
||||
<span>{{ selectedModelTypeLabel }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
defineProps<{
|
||||
const { uploadContext } = defineProps<{
|
||||
metadata?: AssetMetadata
|
||||
previewImage?: string
|
||||
uploadContext?: UploadModelDialogContext
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
@@ -65,4 +113,27 @@ const modelValue = defineModel<string | undefined>()
|
||||
const { modelTypes, isLoading } = useModelTypes()
|
||||
const primeVueOverlay = usePrimeVueOverlayChildStyle()
|
||||
const selectContentStyle = primeVueOverlay.contentStyle
|
||||
|
||||
const isMissingModelResolution = computed(
|
||||
() => uploadContext?.kind === 'missing-model-resolution'
|
||||
)
|
||||
const missingModelName = computed(() =>
|
||||
uploadContext?.kind === 'missing-model-resolution'
|
||||
? uploadContext.missingModelName
|
||||
: ''
|
||||
)
|
||||
const replacementTargets = computed(() =>
|
||||
uploadContext?.kind === 'missing-model-resolution'
|
||||
? uploadContext.replacementTargets
|
||||
: []
|
||||
)
|
||||
const selectedModelTypeLabel = computed(() => {
|
||||
const value =
|
||||
uploadContext?.kind === 'missing-model-resolution'
|
||||
? uploadContext.requiredModelType
|
||||
: modelValue.value
|
||||
return (
|
||||
modelTypes.value.find((option) => option.value === value)?.name ?? value
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
:preview-image="wizardData.previewImage"
|
||||
:upload-context="uploadContext"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
@@ -24,6 +25,7 @@
|
||||
v-else-if="currentStep === 3 && uploadStatus != null"
|
||||
:result="uploadStatus"
|
||||
:error="uploadError"
|
||||
:type-mismatch="uploadTypeMismatch"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
:preview-image="wizardData.previewImage"
|
||||
@@ -39,6 +41,7 @@
|
||||
:can-fetch-metadata="canFetchMetadata"
|
||||
:can-upload-model="canUploadModel"
|
||||
:upload-status="uploadStatus"
|
||||
:can-import-another="!isMissingModelResolution"
|
||||
@back="goToPreviousStep"
|
||||
@fetch-metadata="handleFetchMetadata"
|
||||
@upload="handleUploadModel"
|
||||
@@ -49,29 +52,47 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
|
||||
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
|
||||
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
|
||||
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type {
|
||||
UploadModelDialogContext,
|
||||
UploadModelSuccess
|
||||
} from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { modelTypes, fetchModelTypes } = useModelTypes()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload-success': []
|
||||
const { uploadContext } = defineProps<{
|
||||
uploadContext?: UploadModelDialogContext
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload-success': [result: UploadModelSuccess]
|
||||
}>()
|
||||
|
||||
const isMissingModelResolution = computed(
|
||||
() => uploadContext?.kind === 'missing-model-resolution'
|
||||
)
|
||||
const requiredModelType = computed(() =>
|
||||
uploadContext?.kind === 'missing-model-resolution'
|
||||
? uploadContext.requiredModelType
|
||||
: undefined
|
||||
)
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
isFetchingMetadata,
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
uploadTypeMismatch,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
canFetchMetadata,
|
||||
@@ -80,16 +101,18 @@ const {
|
||||
uploadModel,
|
||||
goToPreviousStep,
|
||||
resetWizard
|
||||
} = useUploadModelWizard(modelTypes)
|
||||
} = useUploadModelWizard(modelTypes, {
|
||||
requiredModelType: requiredModelType.value
|
||||
})
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
await fetchMetadata()
|
||||
}
|
||||
|
||||
async function handleUploadModel() {
|
||||
const success = await uploadModel()
|
||||
if (success) {
|
||||
emit('upload-success')
|
||||
const result = await uploadModel()
|
||||
if (result) {
|
||||
emit('upload-success', result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
src/platform/assets/components/UploadModelFooter.test.ts
Normal file
60
src/platform/assets/components/UploadModelFooter.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import UploadModelFooter from './UploadModelFooter.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function renderFooter(
|
||||
props: Partial<InstanceType<typeof UploadModelFooter>['$props']> = {}
|
||||
) {
|
||||
render(UploadModelFooter, {
|
||||
props: {
|
||||
currentStep: 3,
|
||||
isFetchingMetadata: false,
|
||||
isUploading: false,
|
||||
canFetchMetadata: true,
|
||||
canUploadModel: true,
|
||||
uploadStatus: 'success',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
VideoHelpDialog: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('UploadModelFooter', () => {
|
||||
it('allows importing another model by default', () => {
|
||||
renderFooter()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Import Another' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('disables importing another model when the upload resolves a missing model', () => {
|
||||
renderFooter({ canImportAnother: false })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Import Another' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows recovery actions for upload errors', () => {
|
||||
renderFooter({ uploadStatus: 'error' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,7 @@
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-import-another-button"
|
||||
:disabled="!canImportAnother"
|
||||
@click="emit('importAnother')"
|
||||
>
|
||||
{{ $t('assetBrowser.importAnother') }}
|
||||
@@ -90,6 +91,24 @@
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="currentStep === 3 && uploadStatus === 'error'">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-back-button"
|
||||
@click="emit('back')"
|
||||
>
|
||||
{{ $t('g.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-close-button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
</template>
|
||||
<VideoHelpDialog
|
||||
v-model="showCivitaiHelp"
|
||||
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
|
||||
@@ -113,13 +132,14 @@ import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
|
||||
const showCivitaiHelp = ref(false)
|
||||
const showHuggingFaceHelp = ref(false)
|
||||
|
||||
defineProps<{
|
||||
const { canImportAnother = true } = defineProps<{
|
||||
currentStep: number
|
||||
isFetchingMetadata: boolean
|
||||
isUploading: boolean
|
||||
canFetchMetadata: boolean
|
||||
canUploadModel: boolean
|
||||
uploadStatus?: 'processing' | 'success' | 'error'
|
||||
canImportAnother?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
74
src/platform/assets/components/UploadModelProgress.test.ts
Normal file
74
src/platform/assets/components/UploadModelProgress.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import UploadModelProgress from './UploadModelProgress.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
escapeParameter: true
|
||||
})
|
||||
|
||||
describe('UploadModelProgress', () => {
|
||||
it('renders missing-model type mismatch labels', () => {
|
||||
render(UploadModelProgress, {
|
||||
props: {
|
||||
result: 'error',
|
||||
typeMismatch: {
|
||||
importedModelType: 'loras',
|
||||
importedModelTypeLabel: 'LoRA/Custom',
|
||||
requiredModelType: 'Ultralytics/bbox',
|
||||
requiredModelTypeLabel: 'Ultralytics/bbox'
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText('This model cannot resolve the missing model.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('LoRA/Custom')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Ultralytics/bbox').length).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.getByText((_content, element) => {
|
||||
return (
|
||||
element?.textContent ===
|
||||
'Try importing a different Ultralytics/bbox model that this node can use.'
|
||||
)
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses fallback copy when the imported model type label is unknown', () => {
|
||||
render(UploadModelProgress, {
|
||||
props: {
|
||||
result: 'error',
|
||||
typeMismatch: {
|
||||
requiredModelType: 'checkpoints',
|
||||
requiredModelTypeLabel: 'Checkpoint'
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('another model type')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText((_content, element) => {
|
||||
return (
|
||||
element?.textContent ===
|
||||
'This file is already imported as another model type.'
|
||||
)
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col gap-6 text-sm text-muted-foreground',
|
||||
isTypeMismatchError && 'min-h-full justify-center'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Processing State (202 async download in progress) -->
|
||||
<div v-if="result === 'processing'" class="flex flex-col gap-2">
|
||||
<p class="m-0 font-bold">
|
||||
@@ -67,8 +74,51 @@
|
||||
v-else-if="result === 'error'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] text-6xl text-error" />
|
||||
<div class="text-center">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-error"
|
||||
:class="
|
||||
typeMismatch
|
||||
? 'icon-[lucide--circle-alert] size-12'
|
||||
: 'icon-[lucide--x-circle] size-16'
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="typeMismatch"
|
||||
class="flex max-w-2xl flex-col gap-3 text-center"
|
||||
>
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.missingModelImportTypeMismatchTitle') }}
|
||||
</p>
|
||||
<i18n-t
|
||||
keypath="assetBrowser.missingModelImportTypeMismatchAlreadyImported"
|
||||
tag="p"
|
||||
class="m-0 text-sm text-muted"
|
||||
>
|
||||
<template #actual>
|
||||
<span>{{ actualModelTypeLabel }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<i18n-t
|
||||
keypath="assetBrowser.missingModelImportTypeMismatchRequired"
|
||||
tag="p"
|
||||
class="m-0 text-sm text-muted"
|
||||
>
|
||||
<template #required>
|
||||
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<i18n-t
|
||||
keypath="assetBrowser.missingModelImportTypeMismatchNextAction"
|
||||
tag="p"
|
||||
class="m-0 text-sm text-base-foreground"
|
||||
>
|
||||
<template #required>
|
||||
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadFailed') }}
|
||||
</p>
|
||||
@@ -81,13 +131,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { UploadModelTypeMismatch } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
|
||||
defineProps<{
|
||||
const { typeMismatch } = defineProps<{
|
||||
result: 'processing' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata?: AssetMetadata
|
||||
modelType?: string
|
||||
previewImage?: string
|
||||
typeMismatch?: UploadModelTypeMismatch | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const isTypeMismatchError = computed(() => typeMismatch != null)
|
||||
const actualModelTypeLabel = computed(
|
||||
() =>
|
||||
typeMismatch?.importedModelTypeLabel ??
|
||||
t('assetBrowser.missingModelImportUnknownType')
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -313,9 +313,7 @@ export function useMediaAssetActions() {
|
||||
subfolder: metadata?.subfolder || '',
|
||||
type: isResultItemType(assetType) ? assetType : undefined
|
||||
},
|
||||
{
|
||||
rootFolder: isResultItemType(assetType) ? assetType : undefined
|
||||
}
|
||||
{ rootFolder: 'input' }
|
||||
)
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
|
||||
@@ -3,17 +3,28 @@ import { computed } from 'vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import type {
|
||||
UploadModelDialogContext,
|
||||
UploadModelSuccess
|
||||
} from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
|
||||
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
|
||||
|
||||
export function useModelUpload(
|
||||
onUploadSuccess?: () => Promise<unknown> | void
|
||||
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
|
||||
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
|
||||
) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
|
||||
|
||||
function resolveUploadContext() {
|
||||
return typeof uploadContext === 'function' ? uploadContext() : uploadContext
|
||||
}
|
||||
|
||||
function showUploadDialog() {
|
||||
if (!flags.privateModelsEnabled) {
|
||||
dialogStore.showDialog({
|
||||
@@ -33,8 +44,9 @@ export function useModelUpload(
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
onUploadSuccess: async () => {
|
||||
await onUploadSuccess?.()
|
||||
uploadContext: resolveUploadContext(),
|
||||
onUploadSuccess: async (result: UploadModelSuccess) => {
|
||||
await onUploadSuccess?.(result)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, ref } from 'vue'
|
||||
import type { App } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { useUploadModelWizard } from './useUploadModelWizard'
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetMetadata: vi.fn(),
|
||||
uploadAssetAsync: vi.fn(),
|
||||
uploadAssetPreviewImage: vi.fn()
|
||||
}
|
||||
@@ -45,18 +49,52 @@ vi.mock('@/i18n', () => ({
|
||||
d: (date: Date) => date.toISOString()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
describe('useUploadModelWizard', () => {
|
||||
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
|
||||
const mountedApps: App<Element>[] = []
|
||||
|
||||
function setupWithI18n<T>(factory: () => T): T {
|
||||
let result: T | undefined
|
||||
const host = document.createElement('div')
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = factory()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
)
|
||||
app.mount(host)
|
||||
mountedApps.push(app)
|
||||
|
||||
if (result === undefined) {
|
||||
throw new Error('Composable setup did not run')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function setupUploadModelWizard(
|
||||
...args: Parameters<typeof useUploadModelWizard>
|
||||
): ReturnType<typeof useUploadModelWizard> {
|
||||
return setupWithI18n(() => useUploadModelWizard(...args))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('updates uploadStatus to success when async download completes', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
@@ -71,11 +109,18 @@ describe('useUploadModelWizard', () => {
|
||||
}
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
|
||||
|
||||
const wizard = useUploadModelWizard(modelTypes)
|
||||
const wizard = setupUploadModelWizard(modelTypes)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
await wizard.uploadModel()
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).toEqual({
|
||||
filename: 'model',
|
||||
modelType: 'checkpoints',
|
||||
taskId: 'task-123',
|
||||
status: 'processing'
|
||||
})
|
||||
|
||||
expect(wizard.uploadStatus.value).toBe('processing')
|
||||
|
||||
@@ -118,7 +163,7 @@ describe('useUploadModelWizard', () => {
|
||||
}
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
|
||||
|
||||
const wizard = useUploadModelWizard(modelTypes)
|
||||
const wizard = setupUploadModelWizard(modelTypes)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
@@ -169,7 +214,7 @@ describe('useUploadModelWizard', () => {
|
||||
}
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
|
||||
|
||||
const wizard = useUploadModelWizard(modelTypes)
|
||||
const wizard = setupUploadModelWizard(modelTypes)
|
||||
wizard.wizardData.value.url = 'https://civitai.red/models/12345'
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
@@ -178,4 +223,160 @@ describe('useUploadModelWizard', () => {
|
||||
expect(assetService.uploadAssetAsync).toHaveBeenCalled()
|
||||
expect(wizard.uploadStatus.value).toBe('processing')
|
||||
})
|
||||
|
||||
it('keeps a required model type when metadata suggests another type', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
|
||||
content_length: 100,
|
||||
final_url: 'https://civitai.com/models/12345',
|
||||
filename: 'lora.safetensors',
|
||||
tags: ['loras']
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(
|
||||
ref([
|
||||
{ name: 'Checkpoint', value: 'checkpoints' },
|
||||
{ name: 'LoRA', value: 'loras' }
|
||||
]),
|
||||
{ requiredModelType: 'checkpoints' }
|
||||
)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
await wizard.fetchMetadata()
|
||||
|
||||
expect(wizard.selectedModelType.value).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('uploads with the required model type even if selection changes', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(modelTypes, {
|
||||
requiredModelType: 'checkpoints'
|
||||
})
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
wizard.selectedModelType.value = 'loras'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: expect.objectContaining({
|
||||
model_type: 'checkpoints'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(result?.modelType).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('returns the synced asset filename for sync imports', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-canonical',
|
||||
name: 'asset-record-display-name.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
filename: 'models/checkpoints/canonical-model.safetensors'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(modelTypes)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
wizard.wizardData.value.metadata = {
|
||||
content_length: 100,
|
||||
final_url:
|
||||
'https://civitai.com/api/download/models/canonical-model.safetensors',
|
||||
filename: 'metadata-model.safetensors',
|
||||
tags: ['checkpoints']
|
||||
}
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).toEqual({
|
||||
filename: 'models/checkpoints/canonical-model.safetensors',
|
||||
modelType: 'checkpoints',
|
||||
status: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks a missing-model import when an existing asset has the wrong model type', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-lora',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'loras']
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(
|
||||
ref([
|
||||
{ name: 'Checkpoint', value: 'checkpoints' },
|
||||
{ name: 'LoRA', value: 'loras' }
|
||||
]),
|
||||
{ requiredModelType: 'checkpoints' }
|
||||
)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(wizard.uploadStatus.value).toBe('error')
|
||||
expect(wizard.uploadTypeMismatch.value).toEqual({
|
||||
importedModelType: 'loras',
|
||||
importedModelTypeLabel: 'LoRA',
|
||||
requiredModelType: 'checkpoints',
|
||||
requiredModelTypeLabel: 'Checkpoint'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not block sync imports as mismatches without a required model type', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-lora',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'loras']
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(
|
||||
ref([
|
||||
{ name: 'Checkpoint', value: 'checkpoints' },
|
||||
{ name: 'LoRA', value: 'loras' }
|
||||
])
|
||||
)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
wizard.selectedModelType.value = 'checkpoints'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
modelType: 'checkpoints',
|
||||
status: 'success'
|
||||
})
|
||||
)
|
||||
expect(wizard.uploadStatus.value).toBe('success')
|
||||
expect(wizard.uploadTypeMismatch.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,9 +5,13 @@ import { useI18n } from 'vue-i18n'
|
||||
import { st } from '@/i18n'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetMetadata
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { ImportSource } from '@/platform/assets/types/importSource'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -26,16 +30,54 @@ interface ModelTypeOption {
|
||||
value: string
|
||||
}
|
||||
|
||||
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
const MODEL_ROOT_TAG = 'models'
|
||||
|
||||
export interface UploadModelSuccess {
|
||||
filename: string
|
||||
modelType?: string
|
||||
taskId?: string
|
||||
status: 'processing' | 'success'
|
||||
}
|
||||
|
||||
export interface UploadModelTypeMismatch {
|
||||
importedModelType?: string
|
||||
importedModelTypeLabel?: string
|
||||
requiredModelType: string
|
||||
requiredModelTypeLabel: string
|
||||
}
|
||||
|
||||
interface MissingModelUploadContext {
|
||||
kind: 'missing-model-resolution'
|
||||
missingModelName: string
|
||||
requiredModelType: string
|
||||
replacementTargets: Array<{
|
||||
nodeId: string
|
||||
nodeLabel: string
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type UploadModelDialogContext = MissingModelUploadContext
|
||||
|
||||
interface UploadModelWizardOptions {
|
||||
requiredModelType?: string
|
||||
}
|
||||
|
||||
export function useUploadModelWizard(
|
||||
modelTypes: Ref<ModelTypeOption[]>,
|
||||
options: UploadModelWizardOptions = {}
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const requiredModelType = options.requiredModelType
|
||||
const currentStep = ref(1)
|
||||
const isFetchingMetadata = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref<'processing' | 'success' | 'error'>()
|
||||
const uploadError = ref('')
|
||||
const uploadTypeMismatch = ref<UploadModelTypeMismatch | null>(null)
|
||||
let stopAsyncWatch: (() => void) | undefined
|
||||
|
||||
const wizardData = ref<WizardData>({
|
||||
@@ -44,7 +86,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string>()
|
||||
const selectedModelType = ref<string | undefined>(requiredModelType)
|
||||
const resolvedModelType = computed(
|
||||
() => requiredModelType ?? selectedModelType.value
|
||||
)
|
||||
|
||||
const importSources: ImportSource[] = [
|
||||
civitaiImportSource,
|
||||
@@ -65,16 +110,29 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
() => wizardData.value.url,
|
||||
() => {
|
||||
uploadError.value = ''
|
||||
uploadTypeMismatch.value = null
|
||||
}
|
||||
)
|
||||
|
||||
if (requiredModelType) {
|
||||
watch(
|
||||
selectedModelType,
|
||||
(value) => {
|
||||
if (value !== requiredModelType) {
|
||||
selectedModelType.value = requiredModelType
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
// Validation - only enable Continue when URL matches a supported source
|
||||
const canFetchMetadata = computed(() => {
|
||||
return detectedSource.value !== null
|
||||
})
|
||||
|
||||
const canUploadModel = computed(() => {
|
||||
return !!selectedModelType.value
|
||||
return !!resolvedModelType.value
|
||||
})
|
||||
|
||||
async function fetchMetadata() {
|
||||
@@ -128,7 +186,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
wizardData.value.previewImage = metadata.preview_image
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
if (requiredModelType) {
|
||||
selectedModelType.value = requiredModelType
|
||||
} else if (metadata.tags && metadata.tags.length > 0) {
|
||||
wizardData.value.tags = metadata.tags
|
||||
// Try to detect model type from tags
|
||||
const typeTag = metadata.tags.find((tag) =>
|
||||
@@ -183,10 +243,10 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
}
|
||||
|
||||
async function refreshModelCaches() {
|
||||
if (!selectedModelType.value) return
|
||||
if (!resolvedModelType.value) return
|
||||
|
||||
const providers = modelToNodeStore.getAllNodeProviders(
|
||||
selectedModelType.value
|
||||
resolvedModelType.value
|
||||
)
|
||||
const results = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
@@ -203,24 +263,61 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadModel(): Promise<boolean> {
|
||||
if (isUploading.value) return false
|
||||
function getModelTypeLabel(modelType: string): string {
|
||||
return (
|
||||
modelTypes.value.find((type) => type.value === modelType)?.name ??
|
||||
modelType
|
||||
)
|
||||
}
|
||||
|
||||
function getImportedModelType(asset: AssetItem): string | undefined {
|
||||
const knownType = asset.tags.find(
|
||||
(tag) =>
|
||||
tag !== MODEL_ROOT_TAG &&
|
||||
modelTypes.value.some((type) => type.value === tag)
|
||||
)
|
||||
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
|
||||
}
|
||||
|
||||
function blockMismatchedImportedModel(
|
||||
asset: AssetItem,
|
||||
requiredType: string
|
||||
): boolean {
|
||||
if (asset.tags.includes(requiredType)) return false
|
||||
|
||||
const importedType = getImportedModelType(asset)
|
||||
uploadStatus.value = 'error'
|
||||
uploadError.value = ''
|
||||
uploadTypeMismatch.value = {
|
||||
importedModelType: importedType,
|
||||
importedModelTypeLabel: importedType
|
||||
? getModelTypeLabel(importedType)
|
||||
: undefined,
|
||||
requiredModelType: requiredType,
|
||||
requiredModelTypeLabel: getModelTypeLabel(requiredType)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadModel(): Promise<UploadModelSuccess | null> {
|
||||
if (isUploading.value) return null
|
||||
if (!canUploadModel.value) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
const source = detectedSource.value
|
||||
if (!source) {
|
||||
uploadError.value = t('assetBrowser.noValidSourceDetected')
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
uploadTypeMismatch.value = null
|
||||
let uploadSuccess: UploadModelSuccess | null = null
|
||||
|
||||
try {
|
||||
const tags = selectedModelType.value
|
||||
? ['models', selectedModelType.value]
|
||||
: ['models']
|
||||
const modelType = resolvedModelType.value
|
||||
const tags = modelType ? ['models', modelType] : ['models']
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
@@ -230,7 +327,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
const userMetadata = {
|
||||
source: source.type,
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
model_type: modelType
|
||||
}
|
||||
|
||||
const result = await assetService.uploadAssetAsync({
|
||||
@@ -241,14 +338,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
})
|
||||
|
||||
if (result.type === 'async' && result.task.status !== 'completed') {
|
||||
if (selectedModelType.value) {
|
||||
if (modelType) {
|
||||
assetDownloadStore.trackDownload(
|
||||
result.task.task_id,
|
||||
selectedModelType.value,
|
||||
modelType,
|
||||
filename
|
||||
)
|
||||
}
|
||||
uploadStatus.value = 'processing'
|
||||
uploadSuccess = {
|
||||
filename,
|
||||
modelType,
|
||||
taskId: result.task.task_id,
|
||||
status: 'processing'
|
||||
}
|
||||
|
||||
stopAsyncWatch?.()
|
||||
let resolved = false
|
||||
@@ -288,8 +391,24 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
stopAsyncWatch = stop
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
requiredModelType &&
|
||||
result.type === 'sync' &&
|
||||
modelType &&
|
||||
blockMismatchedImportedModel(result.asset, modelType)
|
||||
) {
|
||||
currentStep.value = 3
|
||||
return null
|
||||
}
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
await refreshModelCaches()
|
||||
uploadSuccess = {
|
||||
filename:
|
||||
result.type === 'sync' ? getAssetFilename(result.asset) : filename,
|
||||
modelType,
|
||||
status: 'success'
|
||||
}
|
||||
}
|
||||
currentStep.value = 3
|
||||
} catch (error) {
|
||||
@@ -301,7 +420,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
return uploadStatus.value !== 'error'
|
||||
return uploadSuccess
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
@@ -318,12 +437,13 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
isUploading.value = false
|
||||
uploadStatus.value = undefined
|
||||
uploadError.value = ''
|
||||
uploadTypeMismatch.value = null
|
||||
wizardData.value = {
|
||||
url: '',
|
||||
name: '',
|
||||
tags: []
|
||||
}
|
||||
selectedModelType.value = undefined
|
||||
selectedModelType.value = requiredModelType
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -333,6 +453,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
uploadTypeMismatch,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
|
||||
|
||||
@@ -20,5 +20,8 @@ export function getAssetType(
|
||||
asset: AssetItem,
|
||||
defaultType: 'input' | 'output' = 'output'
|
||||
): string {
|
||||
return asset.tags?.[0] || defaultType
|
||||
const urlType = new URLSearchParams(
|
||||
(asset.preview_url ?? '').split('?')[1] ?? ''
|
||||
).get('type')
|
||||
return urlType || asset.tags?.[0] || defaultType
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ const { t } = useI18n()
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem] leading-none font-semibold text-base-foreground"
|
||||
class="text-[2rem]/none font-semibold text-base-foreground"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
|
||||
@@ -1394,7 +1394,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Missing Node Packs (1)',
|
||||
displayTitle: 'Missing Node Packs',
|
||||
displayMessage: 'Install missing packs to use this workflow.',
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
@@ -1410,7 +1410,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Unsupported Node Packs (1)',
|
||||
displayTitle: 'Unsupported Node Packs',
|
||||
displayMessage:
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
toastTitle: "FooNode isn't available on Cloud",
|
||||
@@ -1471,7 +1471,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: 'Swap Nodes (1)',
|
||||
displayTitle: 'Swap Nodes',
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
@@ -1520,7 +1520,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Download a model, or open the node to replace it.',
|
||||
toastTitle: 'sdxl.safetensors is missing',
|
||||
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
|
||||
@@ -1535,7 +1535,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayTitle: 'Missing Models',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: "sdxl.safetensors isn't available on Cloud",
|
||||
toastMessage: "This model isn't supported. Choose a different one."
|
||||
@@ -1573,7 +1573,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: 'Missing Inputs (1)',
|
||||
displayTitle: 'Missing Inputs',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.'
|
||||
@@ -1707,7 +1707,7 @@ describe('errorMessageResolver', () => {
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Missing Inputs (2)',
|
||||
displayTitle: 'Missing Inputs',
|
||||
toastTitle: 'Missing media inputs',
|
||||
toastMessage:
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
|
||||
@@ -4,12 +4,9 @@ import type {
|
||||
} from './types'
|
||||
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { countMissingModels } from '@/platform/missingModel/missingModelGrouping'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
return `${title} (${count})`
|
||||
}
|
||||
|
||||
function formatNodeTypeName(nodeType: string): string | null {
|
||||
const trimmed = nodeType.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -171,11 +168,7 @@ type MissingModelSource = Extract<
|
||||
>
|
||||
|
||||
function getMissingModelCount(source: MissingModelSource): number {
|
||||
const count = source.groups.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
return count || source.count
|
||||
return countMissingModels(source.groups) || source.count
|
||||
}
|
||||
|
||||
function resolveMissingModelDisplayMessage(source: MissingModelSource): string {
|
||||
@@ -344,15 +337,12 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_node':
|
||||
return {
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: formatCountTitle(
|
||||
source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
source.count
|
||||
),
|
||||
displayTitle: source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
displayMessage: resolveMissingNodeDisplayMessage(source),
|
||||
toastTitle: resolveMissingNodeToastTitle(source),
|
||||
toastMessage: resolveMissingNodeToastMessage(source)
|
||||
@@ -360,10 +350,7 @@ export function resolveMissingErrorMessage(
|
||||
case 'swap_nodes':
|
||||
return {
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: formatCountTitle(
|
||||
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
source.count
|
||||
),
|
||||
displayTitle: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
displayMessage: resolveSwapNodeDisplayMessage(),
|
||||
toastTitle: resolveSwapNodeToastTitle(source),
|
||||
toastMessage: resolveSwapNodeToastMessage(source)
|
||||
@@ -371,12 +358,9 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_model':
|
||||
return {
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: formatCountTitle(
|
||||
st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
),
|
||||
source.count
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
),
|
||||
displayMessage: resolveMissingModelDisplayMessage(source),
|
||||
toastTitle: resolveMissingModelToastTitle(source),
|
||||
@@ -385,9 +369,9 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_media':
|
||||
return {
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: formatCountTitle(
|
||||
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
|
||||
source.count
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingMedia.missingMediaTitle',
|
||||
'Missing Inputs'
|
||||
),
|
||||
displayMessage: resolveMissingMediaDisplayMessage(),
|
||||
toastTitle: resolveMissingMediaToastTitle(source),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div class="px-3">
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
name="list-scale"
|
||||
@@ -15,7 +15,7 @@
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
{{ item.displayItemLabel }}
|
||||
@@ -25,7 +25,7 @@
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.displayItemLabel
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"properties": {},
|
||||
"widgets_values": [0, "randomize", 20]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-x",
|
||||
"pos": [300, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4,
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-x",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "x",
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [0, 0],
|
||||
"size": [100, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
"widgets_values": ["some_other_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
|
||||
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "rare_model.safetensors",
|
||||
"url": "https://example.com/rare",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,24 +1,37 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type {
|
||||
MissingModelGroup,
|
||||
MissingModelViewModel
|
||||
} from '@/platform/missingModel/types'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
|
||||
vi.mock('./MissingModelRow.vue', () => ({
|
||||
default: {
|
||||
name: 'MissingModelRow',
|
||||
template:
|
||||
'<div class="model-row" :data-show-node-id-badge="showNodeIdBadge" :data-is-asset-supported="isAssetSupported" :data-directory="directory"><button class="locate-trigger" @click="$emit(\'locate-model\', model?.representative?.nodeId)">Locate</button></div>',
|
||||
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
|
||||
template: `
|
||||
<div
|
||||
data-testid="model-row"
|
||||
class="model-row"
|
||||
:data-model-name="model.name"
|
||||
:data-is-asset-supported="isAssetSupported"
|
||||
:data-directory="directory"
|
||||
:data-can-cloud-import="canCloudImport"
|
||||
>
|
||||
<button
|
||||
class="locate-trigger"
|
||||
@click="$emit('locate-model', model?.representative?.nodeId)"
|
||||
>
|
||||
Locate
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
props: ['model', 'directory', 'isAssetSupported', 'canCloudImport'],
|
||||
emits: ['locate-model']
|
||||
}
|
||||
}))
|
||||
@@ -35,21 +48,7 @@ import MissingModelCard from './MissingModelCard.vue'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingModels: {
|
||||
importNotSupported: 'Import Not Supported',
|
||||
customNodeDownloadDisabled:
|
||||
'Cloud environment does not support model imports for custom nodes.',
|
||||
unknownCategory: 'Unknown Category',
|
||||
downloadAll: 'Download all',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing missing models.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
messages: { en: enMessages },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -106,7 +105,6 @@ function makeGroup(
|
||||
function mountCard(
|
||||
props: Partial<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}> = {},
|
||||
onLocateModel?: (nodeId: string) => void
|
||||
) {
|
||||
@@ -114,7 +112,6 @@ function mountCard(
|
||||
return render(MissingModelCard, {
|
||||
props: {
|
||||
missingModelGroups: [makeGroup()],
|
||||
showNodeIdBadge: false,
|
||||
...props,
|
||||
...(onLocateModel ? { onLocateModel } : {})
|
||||
},
|
||||
@@ -124,62 +121,115 @@ function mountCard(
|
||||
})
|
||||
}
|
||||
|
||||
function getRows() {
|
||||
return screen.queryAllByTestId('model-row')
|
||||
}
|
||||
|
||||
function getRowsIn(testId: string) {
|
||||
return within(screen.getByTestId(testId)).getAllByTestId('model-row')
|
||||
}
|
||||
|
||||
describe('MissingModelCard', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders directory name in category header', () => {
|
||||
const { container } = mountCard({
|
||||
it('passes the model directory to rows', () => {
|
||||
mockIsCloud.value = false
|
||||
mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: 'loras' })]
|
||||
})
|
||||
expect(container.textContent).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders translated unknown category when directory is null', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: null })]
|
||||
})
|
||||
expect(container.textContent).toContain('Unknown Category')
|
||||
})
|
||||
|
||||
it('renders model count in category header', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
|
||||
]
|
||||
})
|
||||
expect(container.textContent).toContain('(2)')
|
||||
expect(getRows()[0].getAttribute('data-directory')).toBe('loras')
|
||||
})
|
||||
|
||||
it('renders correct number of MissingModelRow components', () => {
|
||||
const { container } = mountCard({
|
||||
mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({
|
||||
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
|
||||
})
|
||||
]
|
||||
})
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelectorAll('.model-row')).toHaveLength(3)
|
||||
expect(getRows()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders multiple groups', () => {
|
||||
const { container } = mountCard({
|
||||
it('flattens multiple groups into rows', () => {
|
||||
mockIsCloud.value = false
|
||||
mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: 'checkpoints' }),
|
||||
makeGroup({ directory: 'loras' })
|
||||
]
|
||||
})
|
||||
expect(container.textContent).toContain('checkpoints')
|
||||
expect(container.textContent).toContain('loras')
|
||||
expect(getRows()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('sorts importable rows by model type order in cloud', () => {
|
||||
mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: null, modelNames: ['unknown.safetensors'] }),
|
||||
makeGroup({ directory: 'loras', modelNames: ['lora.safetensors'] }),
|
||||
makeGroup({
|
||||
directory: 'checkpoints',
|
||||
modelNames: ['checkpoint.safetensors']
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
getRowsIn('missing-model-importable-rows').map((row) =>
|
||||
row.getAttribute('data-model-name')
|
||||
)
|
||||
).toEqual(['checkpoint.safetensors', 'lora.safetensors'])
|
||||
})
|
||||
|
||||
it('moves cloud rows without import context into the unsupported section', () => {
|
||||
mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({
|
||||
directory: 'checkpoints',
|
||||
modelNames: ['importable.safetensors']
|
||||
}),
|
||||
makeGroup({
|
||||
directory: null,
|
||||
modelNames: ['unknown.safetensors']
|
||||
}),
|
||||
makeGroup({
|
||||
directory: 'loras',
|
||||
isAssetSupported: false,
|
||||
modelNames: ['custom-node-model.safetensors']
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
getRowsIn('missing-model-importable-rows').map((row) =>
|
||||
row.getAttribute('data-model-name')
|
||||
)
|
||||
).toEqual(['importable.safetensors'])
|
||||
|
||||
const unsupportedSection = screen.getByTestId(
|
||||
'missing-model-import-not-supported-section'
|
||||
)
|
||||
expect(
|
||||
within(unsupportedSection)
|
||||
.getAllByTestId('model-row')
|
||||
.map((row) => row.getAttribute('data-model-name'))
|
||||
).toEqual(['custom-node-model.safetensors', 'unknown.safetensors'])
|
||||
expect(
|
||||
within(unsupportedSection).getByText('Import Not Supported')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(unsupportedSection).getByText(
|
||||
/Nodes that reference the models below do not support imported models/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders zero rows when missingModelGroups is empty', () => {
|
||||
const { container } = mountCard({ missingModelGroups: [] })
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelectorAll('.model-row')).toHaveLength(0)
|
||||
mountCard({ missingModelGroups: [] })
|
||||
expect(getRows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hides bulk actions in cloud', () => {
|
||||
@@ -191,43 +241,6 @@ describe('MissingModelCard', () => {
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes props correctly to MissingModelRow children', () => {
|
||||
const { container } = mountCard({ showNodeIdBadge: true })
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const row = container.querySelector('.model-row')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
|
||||
expect(row!.getAttribute('data-is-asset-supported')).toBe('true')
|
||||
expect(row!.getAttribute('data-directory')).toBe('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Unsupported Group', () => {
|
||||
it('shows "Import Not Supported" header for unsupported groups', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(container.textContent).toContain('Import Not Supported')
|
||||
})
|
||||
|
||||
it('shows info notice for unsupported groups', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(container.textContent).toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides info notice for supported groups', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: true })]
|
||||
})
|
||||
expect(container.textContent).not.toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
@@ -251,79 +264,43 @@ describe('MissingModelCard (OSS)', () => {
|
||||
})
|
||||
|
||||
it('shows directory name instead of "Import Not Supported" for unsupported groups', () => {
|
||||
const { container } = mountCard({
|
||||
mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: 'checkpoints', isAssetSupported: false })
|
||||
]
|
||||
})
|
||||
expect(container.textContent).toContain('checkpoints')
|
||||
expect(container.textContent).not.toContain('Import Not Supported')
|
||||
expect(getRows()[0].getAttribute('data-directory')).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('hides info notice for unsupported groups', () => {
|
||||
const { container } = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(container.textContent).not.toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders unknown category for null directory in OSS', () => {
|
||||
const { container } = mountCard({
|
||||
it('passes null directory for unknown category rows in OSS', () => {
|
||||
mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: null, isAssetSupported: false })
|
||||
]
|
||||
})
|
||||
expect(container.textContent).toContain('Unknown Category')
|
||||
expect(container.textContent).not.toContain('Import Not Supported')
|
||||
expect(getRows()[0].hasAttribute('data-directory')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows bulk actions when one model is downloadable', () => {
|
||||
it('shows Download all at the bottom when one model is downloadable', () => {
|
||||
mountCard({
|
||||
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
|
||||
const actions = screen.getByTestId('missing-model-actions')
|
||||
expect(actions).toBeVisible()
|
||||
expect(
|
||||
within(actions).getByRole('button', { name: /Download all/ })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('hides bulk actions when no model is downloadable', () => {
|
||||
it('hides Download all when no model is downloadable', () => {
|
||||
mountCard()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Download all/ })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Refresh' })
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes missing models from the action bar', async () => {
|
||||
mountCard({
|
||||
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
|
||||
})
|
||||
const store = useMissingModelStore()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
|
||||
expect(store.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the Refresh button focusable and announces refresh progress', async () => {
|
||||
mountCard({
|
||||
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
|
||||
})
|
||||
const store = useMissingModelStore()
|
||||
|
||||
store.isRefreshingMissingModels = true
|
||||
await nextTick()
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: 'Refresh' })
|
||||
expect(refreshButton).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(refreshButton).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Refreshing missing models.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,114 +1,60 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div class="px-3">
|
||||
<div
|
||||
v-if="importableModelRows.length > 0"
|
||||
data-testid="missing-model-importable-rows"
|
||||
class="flex flex-col gap-1 overflow-hidden"
|
||||
>
|
||||
<MissingModelRow
|
||||
v-for="row in importableModelRows"
|
||||
:key="row.key"
|
||||
:model="row.model"
|
||||
:directory="row.directory"
|
||||
:is-asset-supported="row.isAssetSupported"
|
||||
:can-cloud-import="true"
|
||||
@locate-model="emit('locateModel', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="unsupportedModelRows.length > 0"
|
||||
data-testid="missing-model-import-not-supported-section"
|
||||
class="flex flex-col gap-1 border-t border-secondary-background pt-3"
|
||||
>
|
||||
<div class="mb-1">
|
||||
<p class="m-0 text-sm font-semibold text-warning-background">
|
||||
{{ t('rightSidePanel.missingModels.importNotSupported') }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 text-xs/relaxed text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
|
||||
</p>
|
||||
</div>
|
||||
<MissingModelRow
|
||||
v-for="row in unsupportedModelRows"
|
||||
:key="row.key"
|
||||
:model="row.model"
|
||||
:directory="row.directory"
|
||||
:is-asset-supported="row.isAssetSupported"
|
||||
:can-cloud-import="false"
|
||||
@locate-model="emit('locateModel', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="downloadableModels.length > 0"
|
||||
data-testid="missing-model-actions"
|
||||
class="flex items-center gap-2 border-b border-interface-stroke py-2"
|
||||
class="flex items-center pt-2"
|
||||
>
|
||||
<Button
|
||||
data-testid="missing-model-download-all"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 min-w-0 flex-1 rounded-lg text-sm"
|
||||
class="h-8 min-w-0 flex-1 rounded-md text-xs"
|
||||
@click="downloadAllModels"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
|
||||
<span class="truncate">{{ downloadAllLabel }}</span>
|
||||
</Button>
|
||||
<!-- Keep this focusable while refreshing so the live status remains discoverable. -->
|
||||
<Button
|
||||
data-testid="missing-model-refresh"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-28 shrink-0 rounded-lg text-sm"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@click="handleRefreshClick"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="missingModelStore.isRefreshingMissingModels"
|
||||
aria-hidden="true"
|
||||
duration="1s"
|
||||
:size="12"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
||||
/>
|
||||
{{ t('rightSidePanel.missingModels.refresh') }}
|
||||
</Button>
|
||||
<span role="status" aria-live="polite" class="sr-only">
|
||||
{{
|
||||
missingModelStore.isRefreshingMissingModels
|
||||
? t('rightSidePanel.missingModels.refreshing')
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Category groups (by directory) -->
|
||||
<div
|
||||
v-for="group in missingModelGroups"
|
||||
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
|
||||
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<!-- Category header -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:class="
|
||||
(isCloud && !group.isAssetSupported) || group.directory === null
|
||||
? 'text-warning-background'
|
||||
: 'text-destructive-background-hover'
|
||||
"
|
||||
>
|
||||
<span v-if="isCloud && !group.isAssetSupported">
|
||||
{{ t('rightSidePanel.missingModels.importNotSupported') }}
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
<span v-else>
|
||||
<i
|
||||
v-if="group.directory === null"
|
||||
aria-hidden="true"
|
||||
class="mr-1 icon-[lucide--triangle-alert] size-3.5 align-text-bottom"
|
||||
/>
|
||||
{{
|
||||
group.directory ??
|
||||
t('rightSidePanel.missingModels.unknownCategory')
|
||||
}}
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Asset unsupported group notice -->
|
||||
<div
|
||||
v-if="isCloud && !group.isAssetSupported"
|
||||
data-testid="missing-model-import-unsupported"
|
||||
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Model rows -->
|
||||
<div class="flex flex-col gap-1 overflow-hidden pl-2">
|
||||
<MissingModelRow
|
||||
v-for="model in group.models"
|
||||
:key="model.name"
|
||||
:model="model"
|
||||
:directory="group.directory"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:is-asset-supported="group.isAssetSupported"
|
||||
@locate-model="emit('locateModel', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -120,15 +66,28 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
|
||||
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const { missingModelGroups, showNodeIdBadge } = defineProps<{
|
||||
interface MissingModelRowEntry {
|
||||
key: string
|
||||
model: MissingModelGroup['models'][number]
|
||||
directory: string | null
|
||||
isAssetSupported: boolean
|
||||
}
|
||||
|
||||
const MODEL_TYPE_SORT_ORDER = [
|
||||
'checkpoints',
|
||||
'loras',
|
||||
'vae',
|
||||
'text_encoders',
|
||||
'diffusion_models'
|
||||
] as const
|
||||
|
||||
const { missingModelGroups } = defineProps<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -138,6 +97,27 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
const sortedModelRows = computed(() =>
|
||||
missingModelGroups
|
||||
.flatMap((group) =>
|
||||
group.models.map((model, index) => ({
|
||||
key: getModelRowKey(group, model, index),
|
||||
model,
|
||||
directory: group.directory,
|
||||
isAssetSupported: group.isAssetSupported
|
||||
}))
|
||||
)
|
||||
.sort((a, b) => compareModelRows(a, b))
|
||||
)
|
||||
|
||||
const importableModelRows = computed(() =>
|
||||
sortedModelRows.value.filter((row) => !isCloud || canCloudImport(row))
|
||||
)
|
||||
|
||||
const unsupportedModelRows = computed(() =>
|
||||
isCloud ? sortedModelRows.value.filter((row) => !canCloudImport(row)) : []
|
||||
)
|
||||
|
||||
const downloadableModels = computed(() => {
|
||||
if (isCloud) return []
|
||||
|
||||
@@ -159,7 +139,37 @@ function downloadAllModels() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefreshClick() {
|
||||
void missingModelStore.refreshMissingModels()
|
||||
function getModelRowKey(
|
||||
group: MissingModelGroup,
|
||||
model: MissingModelGroup['models'][number],
|
||||
index: number
|
||||
) {
|
||||
const supportKey = group.isAssetSupported ? 'supported' : 'unsupported'
|
||||
return [
|
||||
supportKey,
|
||||
group.directory ?? '__unknown__',
|
||||
model.name,
|
||||
String(index)
|
||||
].join('::')
|
||||
}
|
||||
|
||||
function compareModelRows(a: MissingModelRowEntry, b: MissingModelRowEntry) {
|
||||
return (
|
||||
getModelTypeSortIndex(a.directory) - getModelTypeSortIndex(b.directory) ||
|
||||
(a.directory ?? '').localeCompare(b.directory ?? '') ||
|
||||
a.model.name.localeCompare(b.model.name)
|
||||
)
|
||||
}
|
||||
|
||||
function getModelTypeSortIndex(directory: string | null) {
|
||||
if (directory === null) return Number.MAX_SAFE_INTEGER
|
||||
const index = MODEL_TYPE_SORT_ORDER.indexOf(
|
||||
directory as (typeof MODEL_TYPE_SORT_ORDER)[number]
|
||||
)
|
||||
return index === -1 ? MODEL_TYPE_SORT_ORDER.length : index
|
||||
}
|
||||
|
||||
function canCloudImport(row: MissingModelRowEntry) {
|
||||
return row.isAssetSupported && row.directory !== null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
|
||||
<span class="text-xs font-bold text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingModels.or') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:disabled="options.length === 0"
|
||||
@update:model-value="handleSelect"
|
||||
>
|
||||
<SelectTrigger
|
||||
size="md"
|
||||
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
|
||||
>
|
||||
<SelectValue
|
||||
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<template v-if="options.length > 4" #prepend>
|
||||
<div class="px-1 pb-1.5">
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
type="text"
|
||||
:aria-label="t('g.searchPlaceholder', { subject: '' })"
|
||||
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('g.searchPlaceholder', { subject: '' })"
|
||||
@keydown.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SelectItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ option.name }}
|
||||
</SelectItem>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
role="status"
|
||||
class="px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResultsFound') }}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
|
||||
const { options, showDivider = false } = defineProps<{
|
||||
modelValue: string | undefined
|
||||
options: { name: string; value: string }[]
|
||||
showDivider?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const filterQuery = ref('')
|
||||
|
||||
watch(
|
||||
() => options.length,
|
||||
(len) => {
|
||||
if (len <= 4) filterQuery.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
const { results: fuseResults } = useFuse(filterQuery, () => options, {
|
||||
fuseOptions: {
|
||||
keys: ['name'],
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true
|
||||
},
|
||||
matchAllWhenSearchEmpty: true
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
|
||||
|
||||
function handleSelect(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
filterQuery.value = ''
|
||||
emit('select', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
460
src/platform/missingModel/components/MissingModelRow.test.ts
Normal file
460
src/platform/missingModel/components/MissingModelRow.test.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type {
|
||||
UploadModelDialogContext,
|
||||
UploadModelSuccess
|
||||
} from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import type { MissingModelViewModel } from '@/platform/missingModel/types'
|
||||
import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
|
||||
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
|
||||
const mockDownloadModel = vi.hoisted(() => vi.fn())
|
||||
const mockRootGraph = vi.hoisted<{
|
||||
value: Record<string, never> | null
|
||||
}>(() => ({ value: null }))
|
||||
const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
|
||||
const mockApiListeners = vi.hoisted(
|
||||
() => new Map<string, (event: CustomEvent) => void>()
|
||||
)
|
||||
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
|
||||
const mockUploadContext = vi.hoisted(() => ({
|
||||
resolver: undefined as UploadModelContextResolver | undefined
|
||||
}))
|
||||
const mockUploadCallbacks = vi.hoisted(() => ({
|
||||
onUploadSuccess: undefined as
|
||||
| ((result: UploadModelSuccess) => Promise<unknown> | unknown)
|
||||
| undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get rootGraph() {
|
||||
return mockRootGraph.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(
|
||||
(event: string, handler: (event: CustomEvent) => void) => {
|
||||
mockApiListeners.set(event, handler)
|
||||
}
|
||||
),
|
||||
apiURL: vi.fn((path: string) => path),
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', async () => {
|
||||
const actual = await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
getActiveGraphNodeIds: vi.fn(() => new Set()),
|
||||
getNodeByExecutionId: mockGetNodeByExecutionId
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
|
||||
useModelUpload: (
|
||||
onUploadSuccess?: (
|
||||
result: UploadModelSuccess
|
||||
) => Promise<unknown> | unknown,
|
||||
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
|
||||
) => {
|
||||
mockUploadCallbacks.onUploadSuccess = onUploadSuccess
|
||||
mockUploadContext.resolver =
|
||||
typeof uploadContext === 'function' ? uploadContext : () => uploadContext
|
||||
|
||||
return {
|
||||
isUploadButtonEnabled: { value: true },
|
||||
showUploadDialog: mockShowUploadDialog
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: mockCopyToClipboard
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', async () => {
|
||||
const actual = await vi.importActual<typeof MissingModelDownload>(
|
||||
'@/platform/missingModel/missingModelDownload'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
downloadModel: mockDownloadModel,
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
fileSize: null,
|
||||
gatedRepoUrl: null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import MissingModelRow from './MissingModelRow.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const TransitionCollapseStub = {
|
||||
name: 'TransitionCollapse',
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
|
||||
function makeModel(
|
||||
refs: MissingModelViewModel['referencingNodes']
|
||||
): MissingModelViewModel {
|
||||
return {
|
||||
name: 'model.safetensors',
|
||||
representative: {
|
||||
nodeId: refs[0]?.nodeId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
url: 'https://example.com/model.safetensors',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: refs
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(
|
||||
model: MissingModelViewModel,
|
||||
onLocateModel = vi.fn(),
|
||||
isAssetSupported = true,
|
||||
directory: string | null = 'checkpoints',
|
||||
canCloudImport = true
|
||||
) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
render(MissingModelRow, {
|
||||
props: {
|
||||
model,
|
||||
directory,
|
||||
isAssetSupported,
|
||||
canCloudImport,
|
||||
onLocateModel
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
TransitionCollapse: TransitionCollapseStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { onLocateModel }
|
||||
}
|
||||
|
||||
describe('MissingModelRow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockRootGraph.value = null
|
||||
mockApiListeners.clear()
|
||||
mockGetNodeByExecutionId.mockReset()
|
||||
mockUploadContext.resolver = undefined
|
||||
mockUploadCallbacks.onUploadSuccess = undefined
|
||||
})
|
||||
|
||||
it('opens the model import dialog from the cloud row', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Import' }))
|
||||
|
||||
expect(mockShowUploadDialog).toHaveBeenCalledTimes(1)
|
||||
expect(mockUploadContext.resolver?.()).toEqual({
|
||||
kind: 'missing-model-resolution',
|
||||
missingModelName: 'model.safetensors',
|
||||
requiredModelType: 'checkpoints',
|
||||
replacementTargets: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeLabel: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps unsupported cloud rows as reference-only rows', () => {
|
||||
renderRow(
|
||||
makeModel([{ nodeId: '1', widgetName: 'model_name' }]),
|
||||
vi.fn(),
|
||||
true,
|
||||
null,
|
||||
false
|
||||
)
|
||||
|
||||
expect(screen.getByText('model.safetensors')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'CheckpointLoaderSimple' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows row progress as soon as the model import starts', async () => {
|
||||
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
|
||||
const store = useMissingModelStore()
|
||||
|
||||
await mockUploadCallbacks.onUploadSuccess?.({
|
||||
filename: 'downloaded-model.safetensors',
|
||||
modelType: 'checkpoints',
|
||||
taskId: 'task-1',
|
||||
status: 'processing'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
store.importTaskIds['supported::checkpoints::model.safetensors']
|
||||
).toBe('task-1')
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: 'Importing...' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Importing...')
|
||||
expect(screen.getByText('downloaded-model.safetensors')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
|
||||
})
|
||||
|
||||
it('applies the completed imported model to every referencing node', async () => {
|
||||
const graph = {}
|
||||
const firstWidget = {
|
||||
name: 'ckpt_name',
|
||||
value: 'old-first.safetensors',
|
||||
callback: vi.fn()
|
||||
}
|
||||
const secondWidget = {
|
||||
name: 'ckpt_name',
|
||||
value: 'old-second.safetensors',
|
||||
callback: vi.fn()
|
||||
}
|
||||
const firstSetDirtyCanvas = vi.fn()
|
||||
const secondSetDirtyCanvas = vi.fn()
|
||||
mockRootGraph.value = graph
|
||||
mockGetNodeByExecutionId.mockImplementation((_graph, nodeId) => {
|
||||
if (nodeId === '1') {
|
||||
return {
|
||||
widgets: [firstWidget],
|
||||
graph: { setDirtyCanvas: firstSetDirtyCanvas }
|
||||
}
|
||||
}
|
||||
if (nodeId === '2') {
|
||||
return {
|
||||
widgets: [secondWidget],
|
||||
graph: { setDirtyCanvas: secondSetDirtyCanvas }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
renderRow(
|
||||
makeModel([
|
||||
{ nodeId: '1', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '2', widgetName: 'ckpt_name' }
|
||||
])
|
||||
)
|
||||
|
||||
await mockUploadCallbacks.onUploadSuccess?.({
|
||||
filename: 'client-name.safetensors',
|
||||
modelType: 'checkpoints',
|
||||
taskId: 'task-1',
|
||||
status: 'processing'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const handler = mockApiListeners.get('asset_download')
|
||||
expect(handler).toBeDefined()
|
||||
handler!(
|
||||
new CustomEvent('asset_download', {
|
||||
detail: {
|
||||
task_id: 'task-1',
|
||||
asset_name: 'server-name.safetensors',
|
||||
bytes_total: 100,
|
||||
bytes_downloaded: 100,
|
||||
progress: 1,
|
||||
status: 'completed'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(firstWidget.value).toBe('server-name.safetensors')
|
||||
expect(secondWidget.value).toBe('server-name.safetensors')
|
||||
})
|
||||
expect(firstWidget.callback).toHaveBeenCalledWith('server-name.safetensors')
|
||||
expect(secondWidget.callback).toHaveBeenCalledWith(
|
||||
'server-name.safetensors'
|
||||
)
|
||||
expect(firstSetDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
expect(secondSetDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('locates the parent row directly when a cloud model has one reference', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onLocateModel } = renderRow(
|
||||
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
|
||||
|
||||
expect(onLocateModel).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('moves locate actions to expanded child rows when a cloud model has multiple references', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onLocateModel } = renderRow(
|
||||
makeModel([
|
||||
{ nodeId: '1', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '2', widgetName: 'ckpt_name' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show referencing nodes' })
|
||||
)
|
||||
|
||||
const locateButtons = screen.getAllByTestId('missing-model-locate')
|
||||
expect(locateButtons).toHaveLength(2)
|
||||
|
||||
await user.click(locateButtons[1])
|
||||
|
||||
expect(onLocateModel).toHaveBeenCalledWith('2')
|
||||
})
|
||||
|
||||
it('locates the parent row directly when an OSS model has one reference', async () => {
|
||||
mockIsCloud.value = false
|
||||
const user = userEvent.setup()
|
||||
const { onLocateModel } = renderRow(
|
||||
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
|
||||
|
||||
expect(onLocateModel).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('shows no resolution action in OSS rows without a download url', () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-download')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('missing-model-import')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows model type metadata below the model name', () => {
|
||||
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
|
||||
|
||||
expect(screen.getByText('checkpoints')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows downloadable model size beside the model type metadata', async () => {
|
||||
mockIsCloud.value = false
|
||||
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
|
||||
model.representative.url =
|
||||
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
|
||||
|
||||
renderRow(model, vi.fn(), false)
|
||||
const store = useMissingModelStore()
|
||||
store.fileSizes[model.representative.url] = 14 * 1024 ** 3
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByText('checkpoints · 14 GB')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('missing-model-download')).toHaveTextContent(
|
||||
'Download'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows unknown category metadata for models without a directory', () => {
|
||||
renderRow(
|
||||
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]),
|
||||
vi.fn(),
|
||||
true,
|
||||
null
|
||||
)
|
||||
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves locate actions to expanded child rows when an OSS model has multiple references', async () => {
|
||||
mockIsCloud.value = false
|
||||
const user = userEvent.setup()
|
||||
const { onLocateModel } = renderRow(
|
||||
makeModel([
|
||||
{ nodeId: '1', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '2', widgetName: 'ckpt_name' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show referencing nodes' })
|
||||
)
|
||||
|
||||
const locateButtons = screen.getAllByTestId('missing-model-locate')
|
||||
expect(locateButtons).toHaveLength(2)
|
||||
|
||||
await user.click(locateButtons[1])
|
||||
|
||||
expect(onLocateModel).toHaveBeenCalledWith('2')
|
||||
})
|
||||
|
||||
it('shows the OSS download action in the row for downloadable models', async () => {
|
||||
mockIsCloud.value = false
|
||||
const user = userEvent.setup()
|
||||
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
|
||||
model.representative.url =
|
||||
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
|
||||
|
||||
renderRow(model, vi.fn(), false)
|
||||
|
||||
await user.click(screen.getByTestId('missing-model-download'))
|
||||
|
||||
expect(mockDownloadModel).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,205 +1,206 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col pb-3">
|
||||
<!-- Model header -->
|
||||
<div class="flex h-8 w-full items-center gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<p
|
||||
class="text-foreground min-w-0 truncate text-sm font-medium"
|
||||
:title="model.name"
|
||||
>
|
||||
{{ model.name }} ({{ model.referencingNodes.length }})
|
||||
</p>
|
||||
|
||||
<Button
|
||||
data-testid="missing-model-copy-name"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 hover:bg-transparent"
|
||||
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
|
||||
:title="t('rightSidePanel.missingModels.copyModelName')"
|
||||
@click="copyToClipboard(model.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<Button
|
||||
v-if="!isCloud && model.representative.url && !isAssetSupported"
|
||||
data-testid="missing-model-copy-url"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="copyToClipboard(toBrowsableUrl(model.representative.url!))"
|
||||
>
|
||||
{{ t('rightSidePanel.missingModels.copyUrl') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
|
||||
:disabled="!canConfirm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 rounded-lg transition-colors',
|
||||
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
|
||||
)
|
||||
"
|
||||
@click="handleLibrarySelect"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="canConfirm ? 'text-primary' : 'text-foreground'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="model.referencingNodes.length > 0"
|
||||
v-if="hasMultipleReferences"
|
||||
data-testid="missing-model-expand"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingModels.collapseNodes')
|
||||
: t('rightSidePanel.missingModels.expandNodes')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-180'
|
||||
)
|
||||
"
|
||||
@click="toggleModelExpand(modelKey)"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
@click="handleToggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<span class="flex min-w-0 items-center gap-1 text-xs/tight">
|
||||
<button
|
||||
v-if="hasModelLabelControl"
|
||||
ref="modelLabelControl"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 min-w-0 cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
:title="displayModelName"
|
||||
@click="handleModelLabelClick"
|
||||
>
|
||||
{{ displayModelName }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 font-normal wrap-break-word text-base-foreground"
|
||||
:title="displayModelName"
|
||||
>
|
||||
{{ displayModelName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hasMultipleReferences"
|
||||
data-testid="missing-model-reference-count"
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
>
|
||||
{{ model.referencingNodes.length }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="linkLabel"
|
||||
:title="linkLabel"
|
||||
@click="copyModelLink"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--link] size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<span
|
||||
v-if="modelMetadataLabel"
|
||||
class="block text-2xs/tight"
|
||||
:class="
|
||||
isUnknownCategory
|
||||
? 'text-warning-background'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ modelMetadataLabel }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template v-if="isCloud && canCloudImport">
|
||||
<Button
|
||||
v-if="!isCloudImportDownloadActive"
|
||||
data-testid="missing-model-import"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click="showUploadDialog"
|
||||
>
|
||||
{{ t('g.import') }}
|
||||
</Button>
|
||||
<div
|
||||
v-else
|
||||
ref="cloudProgress"
|
||||
role="progressbar"
|
||||
:aria-label="t('rightSidePanel.missingModels.importing')"
|
||||
:aria-valuenow="cloudImportProgressPercent"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
tabindex="-1"
|
||||
class="flex h-8 w-16 shrink-0 items-center"
|
||||
>
|
||||
<span
|
||||
class="block h-1.5 w-full overflow-hidden rounded-full bg-secondary-background-selected"
|
||||
>
|
||||
<span
|
||||
class="block h-full rounded-full bg-primary-background transition-all duration-200 ease-linear"
|
||||
:style="{ width: `${cloudImportProgressPercent}%` }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="isCloudImportDownloadActive"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="sr-only"
|
||||
>
|
||||
{{ t('rightSidePanel.missingModels.importing') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Button
|
||||
v-if="showDownloadAction"
|
||||
data-testid="missing-model-download"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
:aria-label="`${t('g.download')} ${model.name}`"
|
||||
@click="handleDownload"
|
||||
>
|
||||
{{ t('g.download') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
v-if="!hasMultipleReferences && !isUnknownCategory && primaryReference"
|
||||
data-testid="missing-model-locate"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="handleLocatePrimary"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Referencing nodes -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
<ul
|
||||
v-if="showReferenceList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none p-0',
|
||||
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
<li
|
||||
v-for="ref in model.referencingNodes"
|
||||
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
|
||||
class="flex h-7 items-center"
|
||||
class="min-w-0"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ ref.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
data-testid="missing-model-locate"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Status card -->
|
||||
<TransitionCollapse>
|
||||
<MissingModelStatusCard
|
||||
v-if="selectedLibraryModel[modelKey]"
|
||||
:model-name="selectedLibraryModel[modelKey]"
|
||||
:is-download-active="isDownloadActive"
|
||||
:download-status="downloadStatus"
|
||||
:category-mismatch="importCategoryMismatch[modelKey]"
|
||||
@cancel="cancelLibrarySelect(modelKey)"
|
||||
/>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Input area -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="!selectedLibraryModel[modelKey]"
|
||||
class="mt-1 flex flex-col gap-1"
|
||||
>
|
||||
<div v-if="isAssetSupported" class="flex w-full flex-col py-1">
|
||||
<MissingModelUrlInput
|
||||
:model-key="modelKey"
|
||||
:directory="directory"
|
||||
:type-mismatch="typeMismatch"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!isCloud && downloadable"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<Button
|
||||
data-testid="missing-model-download"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex w-full flex-1"
|
||||
:aria-label="`${t('g.download')} ${model.name}`"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
|
||||
/>
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ downloadLabel }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<MissingModelLibrarySelect
|
||||
v-if="!urlInputs[modelKey]"
|
||||
:model-value="getComboValue(model.representative)"
|
||||
:options="comboOptions"
|
||||
:show-divider="isAssetSupported || downloadable"
|
||||
@select="handleComboSelect(modelKey, $event)"
|
||||
/>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
<div class="flex min-h-8 min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
{{
|
||||
getNodeDisplayLabel(ref.nodeId, model.representative.nodeType)
|
||||
}}
|
||||
</button>
|
||||
<Button
|
||||
data-testid="missing-model-locate"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="ml-auto size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
|
||||
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
|
||||
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
|
||||
import type { MissingModelViewModel } from '@/platform/missingModel/types'
|
||||
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
|
||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||
import {
|
||||
useMissingModelInteractions,
|
||||
getModelStateKey,
|
||||
getNodeDisplayLabel,
|
||||
getComboValue
|
||||
getNodeDisplayLabel
|
||||
} from '@/platform/missingModel/composables/useMissingModelInteractions'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
@@ -212,11 +213,16 @@ import {
|
||||
} from '@/platform/missingModel/missingModelDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const { model, directory, isAssetSupported } = defineProps<{
|
||||
const {
|
||||
model,
|
||||
directory,
|
||||
isAssetSupported,
|
||||
canCloudImport = true
|
||||
} = defineProps<{
|
||||
model: MissingModelViewModel
|
||||
directory: string | null
|
||||
showNodeIdBadge: boolean
|
||||
isAssetSupported: boolean
|
||||
canCloudImport?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -231,21 +237,117 @@ const modelKey = computed(() =>
|
||||
)
|
||||
|
||||
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
|
||||
const comboOptions = computed(() => getComboOptions(model.representative))
|
||||
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
|
||||
const expanded = computed(() => isModelExpanded(modelKey.value))
|
||||
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
|
||||
const isUnknownCategory = computed(() => directory === null)
|
||||
const isDownloadActive = computed(
|
||||
() =>
|
||||
downloadStatus.value?.status === 'running' ||
|
||||
downloadStatus.value?.status === 'created'
|
||||
)
|
||||
const isCloudImportDownloadActive = computed(
|
||||
() => isCloud && canCloudImport && isDownloadActive.value
|
||||
)
|
||||
const cloudImportProgressPercent = computed(() =>
|
||||
Math.round((downloadStatus.value?.progress ?? 0) * 100)
|
||||
)
|
||||
const hasMultipleReferences = computed(() => model.referencingNodes.length > 1)
|
||||
const primaryReference = computed(() => model.referencingNodes[0])
|
||||
const hasModelLabelControl = computed(
|
||||
() =>
|
||||
hasMultipleReferences.value ||
|
||||
(!isUnknownCategory.value && !!primaryReference.value)
|
||||
)
|
||||
const linkLabel = computed(() =>
|
||||
model.representative.url
|
||||
? t('rightSidePanel.missingModels.copyUrl')
|
||||
: t('rightSidePanel.missingModels.copyModelName')
|
||||
)
|
||||
|
||||
const store = useMissingModelStore()
|
||||
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
|
||||
storeToRefs(store)
|
||||
const { selectedLibraryModel } = storeToRefs(store)
|
||||
const cloudProgress = useTemplateRef<HTMLElement>('cloudProgress')
|
||||
const modelLabelControl = useTemplateRef<HTMLButtonElement>('modelLabelControl')
|
||||
|
||||
const expanded = computed(
|
||||
() =>
|
||||
store.modelExpandState[modelKey.value] ??
|
||||
(isUnknownCategory.value && hasMultipleReferences.value)
|
||||
)
|
||||
const showReferenceList = computed(
|
||||
() =>
|
||||
(isUnknownCategory.value && model.referencingNodes.length === 1) ||
|
||||
(hasMultipleReferences.value && expanded.value)
|
||||
)
|
||||
|
||||
const displayModelName = computed(() => {
|
||||
if (!isCloudImportDownloadActive.value) return model.name
|
||||
|
||||
return (
|
||||
downloadStatus.value?.assetName ??
|
||||
selectedLibraryModel.value[modelKey.value] ??
|
||||
model.name
|
||||
)
|
||||
})
|
||||
|
||||
const downloadable = computed(() => {
|
||||
const rep = model.representative
|
||||
return !!(
|
||||
rep.url &&
|
||||
rep.directory &&
|
||||
isModelDownloadable({
|
||||
name: rep.name,
|
||||
url: rep.url,
|
||||
directory: rep.directory
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const showDownloadAction = computed(() => !isCloud && downloadable.value)
|
||||
|
||||
const downloadSizeLabel = computed(() => {
|
||||
if (!showDownloadAction.value) return undefined
|
||||
|
||||
const url = model.representative.url
|
||||
const size = url ? store.fileSizes[url] : undefined
|
||||
return size ? formatSize(size) : undefined
|
||||
})
|
||||
const modelTypeLabel = computed(
|
||||
() => directory ?? t('rightSidePanel.missingModels.unknownCategory')
|
||||
)
|
||||
const modelMetadataLabel = computed(() =>
|
||||
[modelTypeLabel.value, downloadSizeLabel.value].filter(Boolean).join(' · ')
|
||||
)
|
||||
|
||||
const missingModelUploadContext = computed<
|
||||
UploadModelDialogContext | undefined
|
||||
>(() => {
|
||||
if (!canCloudImport || !directory) return undefined
|
||||
|
||||
return {
|
||||
kind: 'missing-model-resolution',
|
||||
missingModelName: model.name,
|
||||
requiredModelType: directory,
|
||||
replacementTargets: model.referencingNodes.map((ref) => ({
|
||||
nodeId: String(ref.nodeId),
|
||||
nodeLabel: getNodeDisplayLabel(ref.nodeId, model.representative.nodeType),
|
||||
widgetName: ref.widgetName
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const { showUploadDialog } = useModelUpload(
|
||||
(result) => {
|
||||
handleUploadedModelImport(modelKey.value, result)
|
||||
|
||||
if (result.status === 'success') {
|
||||
handleLibrarySelect()
|
||||
}
|
||||
},
|
||||
() => missingModelUploadContext.value
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (isCloud) return
|
||||
|
||||
const url = model.representative.url
|
||||
if (url && !store.fileSizes[url]) {
|
||||
fetchModelMetadata(url)
|
||||
@@ -263,27 +365,6 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const downloadable = computed(() => {
|
||||
const rep = model.representative
|
||||
return !!(
|
||||
!isAssetSupported &&
|
||||
rep.url &&
|
||||
rep.directory &&
|
||||
isModelDownloadable({
|
||||
name: rep.name,
|
||||
url: rep.url,
|
||||
directory: rep.directory
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const downloadLabel = computed(() => {
|
||||
const base = t('g.download')
|
||||
const url = model.representative.url
|
||||
const size = url ? store.fileSizes[url] : undefined
|
||||
return size ? `${base} (${formatSize(size)})` : base
|
||||
})
|
||||
|
||||
function handleDownload() {
|
||||
const rep = model.representative
|
||||
if (rep.url && rep.directory) {
|
||||
@@ -296,17 +377,53 @@ function handleDownload() {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
toggleModelExpand,
|
||||
isModelExpanded,
|
||||
getComboOptions,
|
||||
handleComboSelect,
|
||||
isSelectionConfirmable,
|
||||
cancelLibrarySelect,
|
||||
confirmLibrarySelect,
|
||||
getTypeMismatch,
|
||||
getDownloadStatus
|
||||
} = useMissingModelInteractions()
|
||||
function handleLocatePrimary() {
|
||||
const ref = primaryReference.value
|
||||
if (ref) emit('locateModel', String(ref.nodeId))
|
||||
}
|
||||
|
||||
function copyModelLink() {
|
||||
const url = model.representative.url
|
||||
copyToClipboard(url ? toBrowsableUrl(url) : model.name)
|
||||
}
|
||||
|
||||
const { confirmLibrarySelect, getDownloadStatus, handleUploadedModelImport } =
|
||||
useMissingModelInteractions()
|
||||
|
||||
function handleToggleExpand() {
|
||||
store.modelExpandState[modelKey.value] = !expanded.value
|
||||
}
|
||||
|
||||
function handleModelLabelClick() {
|
||||
if (hasMultipleReferences.value) {
|
||||
handleToggleExpand()
|
||||
return
|
||||
}
|
||||
|
||||
handleLocatePrimary()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => downloadStatus.value?.status,
|
||||
(status) => {
|
||||
if (!isCloud || status !== 'completed') return
|
||||
const completedAssetName = downloadStatus.value?.assetName
|
||||
if (completedAssetName) {
|
||||
selectedLibraryModel.value[modelKey.value] = completedAssetName
|
||||
}
|
||||
handleLibrarySelect()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(isCloudImportDownloadActive, async (isActive, wasActive) => {
|
||||
await nextTick()
|
||||
if (isActive) {
|
||||
cloudProgress.value?.focus()
|
||||
} else if (wasActive) {
|
||||
modelLabelControl.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
function handleLibrarySelect() {
|
||||
confirmLibrarySelect(
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
aria-live="polite"
|
||||
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
|
||||
>
|
||||
<!-- Progress bar fill -->
|
||||
<div
|
||||
v-if="isDownloadActive"
|
||||
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
|
||||
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<i
|
||||
v-if="categoryMismatch"
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="downloadStatus?.status === 'failed'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="downloadStatus?.status === 'completed'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check-circle] size-5 text-success-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="isDownloadActive"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--file-check] size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span class="text-foreground truncate text-xs/tight font-medium">
|
||||
{{ modelName }}
|
||||
</span>
|
||||
<span class="mt-0.5 text-xs/tight text-muted-foreground">
|
||||
<template v-if="categoryMismatch">
|
||||
{{
|
||||
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
|
||||
category: categoryMismatch
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="isDownloadActive">
|
||||
{{ t('rightSidePanel.missingModels.importing') }}
|
||||
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
|
||||
</template>
|
||||
<template v-else-if="downloadStatus?.status === 'completed'">
|
||||
{{ t('rightSidePanel.missingModels.imported') }}
|
||||
</template>
|
||||
<template v-else-if="downloadStatus?.status === 'failed'">
|
||||
{{
|
||||
downloadStatus?.error ||
|
||||
t('rightSidePanel.missingModels.importFailed')
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
|
||||
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
const {
|
||||
modelName,
|
||||
isDownloadActive,
|
||||
downloadStatus = null,
|
||||
categoryMismatch = null
|
||||
} = defineProps<{
|
||||
modelName: string
|
||||
isDownloadActive: boolean
|
||||
downloadStatus?: AssetDownload | null
|
||||
categoryMismatch?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,184 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
|
||||
const mockPrivateModelsEnabled = vi.hoisted(() => ({ value: true }))
|
||||
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
|
||||
const mockHandleUrlInput = vi.hoisted(() => vi.fn())
|
||||
const mockHandleImport = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get privateModelsEnabled() {
|
||||
return mockPrivateModelsEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
|
||||
useModelUpload: () => ({
|
||||
isUploadButtonEnabled: { value: true },
|
||||
showUploadDialog: mockShowUploadDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/missingModel/composables/useMissingModelInteractions',
|
||||
() => ({
|
||||
useMissingModelInteractions: () => ({
|
||||
handleUrlInput: mockHandleUrlInput,
|
||||
handleImport: mockHandleImport
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/components/rightSidePanel/layout/TransitionCollapse.vue', () => ({
|
||||
default: {
|
||||
name: 'TransitionCollapse',
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
import MissingModelUrlInput from './MissingModelUrlInput.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { loading: 'Loading' },
|
||||
rightSidePanel: {
|
||||
missingModels: {
|
||||
urlPlaceholder: 'Paste model URL...',
|
||||
clearUrl: 'Clear URL',
|
||||
import: 'Import',
|
||||
importAnyway: 'Import Anyway',
|
||||
typeMismatch: 'Type mismatch: {detectedType}',
|
||||
unsupportedUrl: 'Unsupported URL',
|
||||
metadataFetchFailed: 'Failed to fetch metadata',
|
||||
importFailed: 'Import failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const MODEL_KEY = 'supported::checkpoints::model.safetensors'
|
||||
|
||||
function renderComponent(
|
||||
props: Partial<{
|
||||
modelKey: string
|
||||
directory: string | null
|
||||
typeMismatch: string | null
|
||||
}> = {}
|
||||
) {
|
||||
return render(MissingModelUrlInput, {
|
||||
props: {
|
||||
modelKey: MODEL_KEY,
|
||||
directory: 'checkpoints',
|
||||
typeMismatch: null,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MissingModelUrlInput', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockPrivateModelsEnabled.value = true
|
||||
mockShowUploadDialog.mockClear()
|
||||
mockHandleUrlInput.mockClear()
|
||||
mockHandleImport.mockClear()
|
||||
})
|
||||
|
||||
describe('URL input is always editable', () => {
|
||||
it('input is editable when privateModelsEnabled is true', () => {
|
||||
mockPrivateModelsEnabled.value = true
|
||||
renderComponent()
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).not.toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('input is editable when privateModelsEnabled is false (free tier)', () => {
|
||||
mockPrivateModelsEnabled.value = false
|
||||
renderComponent()
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).not.toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('input accepts user typing when privateModelsEnabled is false', async () => {
|
||||
mockPrivateModelsEnabled.value = false
|
||||
renderComponent()
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
input.value = 'https://example.com/model.safetensors'
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.input(input)
|
||||
expect(mockHandleUrlInput).toHaveBeenCalledWith(
|
||||
MODEL_KEY,
|
||||
'https://example.com/model.safetensors'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import button gates on subscription', () => {
|
||||
it('calls handleImport when privateModelsEnabled is true', async () => {
|
||||
mockPrivateModelsEnabled.value = true
|
||||
const user = userEvent.setup()
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata[MODEL_KEY] = {
|
||||
filename: 'model.safetensors',
|
||||
content_length: 1024,
|
||||
final_url: 'https://example.com/model.safetensors'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
const importBtn = screen.getByRole('button', { name: /Import/ })
|
||||
expect(importBtn).toBeInTheDocument()
|
||||
await user.click(importBtn)
|
||||
|
||||
expect(mockHandleImport).toHaveBeenCalledWith(MODEL_KEY, 'checkpoints')
|
||||
expect(mockShowUploadDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls showUploadDialog when privateModelsEnabled is false (free tier)', async () => {
|
||||
mockPrivateModelsEnabled.value = false
|
||||
const user = userEvent.setup()
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata[MODEL_KEY] = {
|
||||
filename: 'model.safetensors',
|
||||
content_length: 1024,
|
||||
final_url: 'https://example.com/model.safetensors'
|
||||
}
|
||||
|
||||
renderComponent()
|
||||
const importBtn = screen.getByRole('button', { name: /Import/ })
|
||||
expect(importBtn).toBeInTheDocument()
|
||||
await user.click(importBtn)
|
||||
|
||||
expect(mockShowUploadDialog).toHaveBeenCalled()
|
||||
expect(mockHandleImport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clear button works for free-tier users', async () => {
|
||||
mockPrivateModelsEnabled.value = false
|
||||
const user = userEvent.setup()
|
||||
const store = useMissingModelStore()
|
||||
store.urlInputs[MODEL_KEY] = 'https://example.com/model.safetensors'
|
||||
renderComponent()
|
||||
const clearBtn = screen.getByRole('button', { name: 'Clear URL' })
|
||||
await user.click(clearBtn)
|
||||
expect(mockHandleUrlInput).toHaveBeenCalledWith(MODEL_KEY, '')
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user