mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-01 10:09:08 +00:00
Compare commits
5 Commits
docs/stagi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9466947b2 | ||
|
|
bb96e3c95c | ||
|
|
df42b7a2a8 | ||
|
|
4f3a5ae184 | ||
|
|
c77c8a9476 |
@@ -62,37 +62,6 @@ python main.py --port 8188 --cpu
|
||||
- Run `pnpm dev:electron` to start the dev server with electron API mocked
|
||||
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
|
||||
|
||||
#### Testing with Cloud & Staging Environments
|
||||
|
||||
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
|
||||
|
||||
**Option 1: Frontend — `pnpm dev:cloud`**
|
||||
|
||||
The simplest approach. This proxies all API requests to the test cloud environment:
|
||||
|
||||
```bash
|
||||
pnpm dev:cloud
|
||||
```
|
||||
|
||||
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
|
||||
```
|
||||
|
||||
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
|
||||
|
||||
**Option 2: Backend — `--comfy-api-base`**
|
||||
|
||||
Alternatively, launch the ComfyUI backend pointed at the staging API:
|
||||
|
||||
```bash
|
||||
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
|
||||
```
|
||||
|
||||
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
|
||||
|
||||
#### Access dev server on touch devices
|
||||
|
||||
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||
|
||||
68
browser_tests/assets/missing/missing_media_multiple.json
Normal file
68
browser_tests/assets/missing/missing_media_multiple.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"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": ["nonexistent_test_image_aaa.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 50],
|
||||
"size": [315, 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": ["nonexistent_test_image_bbb.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
42
browser_tests/assets/missing/missing_media_single.json
Normal file
42
browser_tests/assets/missing/missing_media_single.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"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": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
BIN
browser_tests/assets/test_upload_image.png
Normal file
BIN
browser_tests/assets/test_upload_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -44,7 +44,15 @@ export const TestIds = {
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model'
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
@@ -61,6 +69,16 @@ export const TestIds = {
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
shownSection: 'subgraph-editor-shown-section',
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
widgetToggle: 'subgraph-widget-toggle',
|
||||
widgetLabel: 'subgraph-widget-label',
|
||||
iconLink: 'icon-link',
|
||||
iconEye: 'icon-eye',
|
||||
widgetActionsMenuButton: 'widget-actions-menu-button'
|
||||
},
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
},
|
||||
@@ -70,6 +88,9 @@ export const TestIds = {
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
},
|
||||
widgets: {
|
||||
container: 'node-widgets',
|
||||
widget: 'node-widget',
|
||||
@@ -131,5 +152,7 @@ export type TestIdValue =
|
||||
(id: string) => string
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.menu)[keyof typeof TestIds.menu]
|
||||
| (typeof TestIds.subgraphEditor)[keyof typeof TestIds.subgraphEditor]
|
||||
| (typeof TestIds.queue)[keyof typeof TestIds.queue]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
|
||||
98
browser_tests/fixtures/utils/slotBoundsUtil.ts
Normal file
98
browser_tests/fixtures/utils/slotBoundsUtil.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
export interface NodeSlotData {
|
||||
nodeId: string
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect slot center offsets relative to the parent node element.
|
||||
* Returns `null` when the node element is not found.
|
||||
*/
|
||||
export async function measureNodeSlotOffsets(
|
||||
page: Page,
|
||||
nodeId: string
|
||||
): Promise<NodeSlotData | null> {
|
||||
return page.evaluate((id) => {
|
||||
const nodeEl = document.querySelector(`[data-node-id="${id}"]`)
|
||||
if (!nodeEl || !(nodeEl instanceof HTMLElement)) return null
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
slots.push({
|
||||
key: (slotEl as HTMLElement).dataset.slotKey ?? 'unknown',
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: id,
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that every slot falls within the node dimensions (± `margin` px).
|
||||
*/
|
||||
export function expectSlotsWithinBounds(
|
||||
data: NodeSlotData,
|
||||
margin: number,
|
||||
label?: string
|
||||
) {
|
||||
const prefix = label ? `${label}: ` : ''
|
||||
|
||||
for (const slot of data.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-margin)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`${prefix}Slot ${slot.key} X=${slot.offsetX} outside width=${data.nodeW}`
|
||||
).toBeLessThanOrEqual(data.nodeW + margin)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-margin)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`${prefix}Slot ${slot.key} Y=${slot.offsetY} outside height=${data.nodeH}`
|
||||
).toBeLessThanOrEqual(data.nodeH + margin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for slots, measure, and assert within bounds — single-node convenience.
|
||||
*/
|
||||
export async function assertNodeSlotsWithinBounds(
|
||||
page: Page,
|
||||
nodeId: string,
|
||||
margin: number = 20
|
||||
) {
|
||||
await page
|
||||
.locator(`[data-node-id="${nodeId}"] [data-slot-key]`)
|
||||
.first()
|
||||
.waitFor()
|
||||
|
||||
const data = await measureNodeSlotOffsets(page, nodeId)
|
||||
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
|
||||
expectSlotsWithinBounds(data!, margin, `Node ${nodeId}`)
|
||||
}
|
||||
68
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
68
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { assertNodeSlotsWithinBounds } from '../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
const NODE_ID = '3'
|
||||
const NODE_TITLE = 'KSampler'
|
||||
|
||||
test.describe(
|
||||
'Collapsed node link positions',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('link endpoints stay within collapsed node bounds', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links follow collapsed node after drag', async ({ comfyPage }) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2 + 200,
|
||||
box!.y + box!.height / 2 + 100,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
|
||||
test('links recover correct positions after expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(NODE_TITLE)
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertNodeSlotsWithinBounds(comfyPage.page, NODE_ID)
|
||||
})
|
||||
}
|
||||
)
|
||||
225
browser_tests/tests/missingMedia.spec.ts
Normal file
225
browser_tests/tests/missingMedia.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function loadMissingMediaAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow = 'missing/missing_media_single'
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow(workflow)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaUploadDropzone
|
||||
)
|
||||
const [fileChooser] = await Promise.all([
|
||||
comfyPage.page.waitForEvent('filechooser'),
|
||||
dropzone.click()
|
||||
])
|
||||
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
}
|
||||
|
||||
async function confirmPendingSelection(comfyPage: ComfyPage) {
|
||||
const confirmButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaConfirmButton
|
||||
)
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
await confirmButton.click()
|
||||
}
|
||||
|
||||
function getMediaRow(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
|
||||
}
|
||||
|
||||
function getStatusCard(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
|
||||
}
|
||||
|
||||
function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
test.describe('Missing media inputs in Error Tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows error overlay when workflow has missing media inputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing required inputs/i)
|
||||
})
|
||||
|
||||
test('Shows missing media group in errors tab after clicking See Errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_multiple'
|
||||
)
|
||||
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Does not show error overlay when all media inputs exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Upload flow (2-step confirm)', () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Library select flow (2-step confirm)', () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
)
|
||||
await librarySelect.getByRole('combobox').click()
|
||||
|
||||
const optionCount = await comfyPage.page.getByRole('option').count()
|
||||
if (optionCount === 0) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.getByRole('option').first().click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cancel selection', () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).not.toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
|
||||
.click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).not.toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('All resolved', () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Locate node', () => {
|
||||
test('Locate button navigates canvas to the missing media node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadMissingMediaAndOpenErrorsTab(comfyPage)
|
||||
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLocateButton
|
||||
)
|
||||
await expect(locateButton).toBeVisible()
|
||||
await locateButton.click()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
})
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '../../fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '../../fixtures/helpers/SubgraphHelper'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
} from '../../fixtures/utils/slotBoundsUtil'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
@@ -19,20 +23,6 @@ const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
isSubgraph: boolean
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
slots: SlotMeasurement[]
|
||||
}
|
||||
|
||||
test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -604,71 +594,19 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for slot elements to appear in DOM
|
||||
await comfyPage.page.locator('[data-slot-key]').first().waitFor()
|
||||
|
||||
const result: NodeSlotData[] = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.graph._nodes
|
||||
const slotData: NodeSlotData[] = []
|
||||
const nodeIds = await comfyPage.page.evaluate(() =>
|
||||
window
|
||||
.app!.graph._nodes.filter((n) => !!n.isSubgraphNode?.())
|
||||
.map((n) => String(n.id))
|
||||
)
|
||||
expect(nodeIds.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeId = String(node.id)
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) continue
|
||||
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
if (slotEls.length === 0) continue
|
||||
|
||||
const slots: SlotMeasurement[] = []
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown'
|
||||
slots.push({
|
||||
key: slotKey,
|
||||
offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left,
|
||||
offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
})
|
||||
}
|
||||
|
||||
slotData.push({
|
||||
nodeId,
|
||||
isSubgraph: !!node.isSubgraphNode?.(),
|
||||
nodeW: nodeRect.width,
|
||||
nodeH: nodeRect.height,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
return slotData
|
||||
})
|
||||
|
||||
const subgraphNodes = result.filter((n) => n.isSubgraph)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
|
||||
for (const node of subgraphNodes) {
|
||||
for (const slot of node.slots) {
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetX,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}`
|
||||
).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN)
|
||||
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN)
|
||||
expect(
|
||||
slot.offsetY,
|
||||
`Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}`
|
||||
).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN)
|
||||
}
|
||||
for (const nodeId of nodeIds) {
|
||||
const data = await measureNodeSlotOffsets(comfyPage.page, nodeId)
|
||||
expect(data, `Node ${nodeId} not found in DOM`).not.toBeNull()
|
||||
expectSlotsWithinBounds(data!, SLOT_BOUNDS_MARGIN, `Node ${nodeId}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
148
browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts
Normal file
148
browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function ensurePropertiesPanel(comfyPage: ComfyPage) {
|
||||
const panel = comfyPage.menu.propertiesPanel.root
|
||||
if (!(await panel.isVisible())) {
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
}
|
||||
await expect(panel).toBeVisible()
|
||||
return panel
|
||||
}
|
||||
|
||||
async function selectSubgraphAndOpenEditor(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const subgraphNodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
await subgraphNodes[0].click('title')
|
||||
|
||||
await ensurePropertiesPanel(comfyPage)
|
||||
|
||||
const editorToggle = comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle)
|
||||
await expect(editorToggle).toBeVisible()
|
||||
await editorToggle.click()
|
||||
|
||||
const shownSection = comfyPage.page.getByTestId(
|
||||
TestIds.subgraphEditor.shownSection
|
||||
)
|
||||
await expect(shownSection).toBeVisible()
|
||||
return shownSection
|
||||
}
|
||||
|
||||
async function collectWidgetLabels(shownSection: Locator) {
|
||||
const labels = shownSection.getByTestId(TestIds.subgraphEditor.widgetLabel)
|
||||
const texts = await labels.allTextContents()
|
||||
return texts.map((t) => t.trim())
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget panel',
|
||||
{ tag: ['@node', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('SubgraphEditor (Settings panel)', () => {
|
||||
test('linked promoted widgets have hide toggle disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Sub 0'
|
||||
)
|
||||
|
||||
const toggleButtons = shownSection.getByTestId(
|
||||
TestIds.subgraphEditor.widgetToggle
|
||||
)
|
||||
await expect(toggleButtons.first()).toBeVisible()
|
||||
|
||||
const count = await toggleButtons.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(toggleButtons.nth(i)).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
test('linked promoted widgets show link icon instead of eye icon', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Sub 0'
|
||||
)
|
||||
|
||||
const linkIcons = shownSection.getByTestId(
|
||||
TestIds.subgraphEditor.iconLink
|
||||
)
|
||||
await expect(linkIcons.first()).toBeVisible()
|
||||
|
||||
const eyeIcons = shownSection.getByTestId(
|
||||
TestIds.subgraphEditor.iconEye
|
||||
)
|
||||
await expect(eyeIcons).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('widget labels display renamed values instead of raw names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/test-values-input-subgraph'
|
||||
)
|
||||
const shownSection = await selectSubgraphAndOpenEditor(
|
||||
comfyPage,
|
||||
'Input Test Subgraph'
|
||||
)
|
||||
|
||||
const allTexts = await collectWidgetLabels(shownSection)
|
||||
expect(allTexts.length).toBeGreaterThan(0)
|
||||
|
||||
// The fixture has a widget with name="text" but
|
||||
// label="renamed_from_sidepanel". The panel should show the
|
||||
// renamed label, not the raw widget name.
|
||||
expect(allTexts).toContain('renamed_from_sidepanel')
|
||||
expect(allTexts).not.toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Parameters tab (WidgetActions menu)', () => {
|
||||
test('linked promoted widget menu should not show Hide/Show input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
|
||||
expect(subgraphNodes.length).toBeGreaterThan(0)
|
||||
await subgraphNodes[0].click('title')
|
||||
|
||||
const panel = await ensurePropertiesPanel(comfyPage)
|
||||
|
||||
const moreButtons = panel.getByTestId(
|
||||
TestIds.subgraphEditor.widgetActionsMenuButton
|
||||
)
|
||||
await expect(moreButtons.first()).toBeVisible()
|
||||
await moreButtons.first().click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByText('Hide input')).toHaveCount(0)
|
||||
await expect(menu.getByText('Show input')).toHaveCount(0)
|
||||
await expect(menu.getByText('Rename')).toBeVisible()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -51,7 +51,10 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<div
|
||||
class="flex min-w-40 flex-col gap-2 p-2"
|
||||
data-testid="more-menu-content"
|
||||
>
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -15,7 +15,7 @@ describe('getDomWidgetZIndex', () => {
|
||||
first.order = 0
|
||||
second.order = 1
|
||||
|
||||
const nodes = fromAny<{ _nodes: LGraphNode[] }, unknown>(graph)._nodes
|
||||
const nodes = fromPartial<{ _nodes: LGraphNode[] }>(graph)._nodes
|
||||
nodes.splice(nodes.indexOf(first), 1)
|
||||
nodes.push(first)
|
||||
|
||||
|
||||
@@ -197,4 +197,15 @@ onBeforeUnmount(() => {
|
||||
:deep(.p-panel-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.p-slider) {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:deep(.p-slider-handle) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -4px;
|
||||
margin-left: -7px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<label>
|
||||
{{ t('load3d.viewer.cameraType') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.viewer.cameraType') }}</label>
|
||||
<Select
|
||||
v-model="cameraType"
|
||||
:options="cameras"
|
||||
@@ -13,7 +11,7 @@
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="showFOVButton" class="space-y-4">
|
||||
<div v-if="showFOVButton" class="flex flex-col gap-2">
|
||||
<label>{{ t('load3d.fov') }}</label>
|
||||
<Slider
|
||||
v-model="fov"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.lightIntensity') }}</label>
|
||||
|
||||
<Slider
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.upDirection') }}</label>
|
||||
<Select
|
||||
v-model="upDirection"
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideMaterialMode">
|
||||
<div v-if="!hideMaterialMode" class="flex flex-col gap-2">
|
||||
<label>{{ $t('load3d.materialMode') }}</label>
|
||||
<Select
|
||||
v-model="materialMode"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<div v-if="!hasBackgroundImage" class="flex flex-col gap-2">
|
||||
<label>
|
||||
{{ $t('load3d.backgroundColor') }}
|
||||
</label>
|
||||
<input v-model="backgroundColor" type="color" class="w-full" />
|
||||
<input v-model="backgroundColor" type="color" class="h-8 w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -303,6 +303,7 @@ function handleTitleCancel() {
|
||||
v-if="isSingleSubgraphNode"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
data-testid="subgraph-editor-toggle"
|
||||
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
||||
@click="
|
||||
rightSidePanelStore.openPanel(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<div
|
||||
v-if="singleRuntimeErrorCard"
|
||||
data-testid="runtime-error-panel"
|
||||
aria-live="polite"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
||||
>
|
||||
<div
|
||||
@@ -168,7 +169,15 @@
|
||||
v-else-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateModel"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-else-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
@@ -225,6 +234,7 @@ import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
downloadModel,
|
||||
@@ -261,7 +271,8 @@ const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model'
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
@@ -283,6 +294,7 @@ const {
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
@@ -393,7 +405,7 @@ function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateModel(nodeId: string) {
|
||||
function handleLocateAssetNode(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,3 +25,4 @@ export type ErrorGroup =
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
| { type: 'missing_model'; title: string; priority: number }
|
||||
| { type: 'missing_media'; title: string; priority: number }
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useErrorActions() {
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
return commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
function findOnGitHub(errorMessage: string) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
@@ -29,7 +30,9 @@ import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
@@ -239,6 +242,7 @@ export function useErrorGroups(
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
@@ -635,6 +639,27 @@ export function useErrorGroups(
|
||||
]
|
||||
}
|
||||
|
||||
const missingMediaGroups = computed<MissingMediaGroup[]>(() => {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
return groupCandidatesByMediaType(candidates)
|
||||
})
|
||||
|
||||
function buildMissingMediaGroups(): ErrorGroup[] {
|
||||
if (!missingMediaGroups.value.length) return []
|
||||
const totalItems = missingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
)
|
||||
return [
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -645,6 +670,7 @@ export function useErrorGroups(
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
@@ -663,6 +689,7 @@ export function useErrorGroups(
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
@@ -699,6 +726,7 @@ export function useErrorGroups(
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
|
||||
import { computed, reactive, toValue, watch } from 'vue'
|
||||
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
@@ -28,66 +28,73 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
|
||||
)
|
||||
})
|
||||
|
||||
let cancelled = false
|
||||
onUnmounted(() => {
|
||||
cancelled = true
|
||||
})
|
||||
watch(
|
||||
() => toValue(cardSource),
|
||||
async (card, _, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const card = toValue(cardSource)
|
||||
const runtimeErrors = card.errors
|
||||
.map((error, idx) => ({ error, idx }))
|
||||
.filter(({ error }) => error.isRuntimeError)
|
||||
for (const key of Object.keys(enrichedDetails)) {
|
||||
delete enrichedDetails[key as unknown as number]
|
||||
}
|
||||
|
||||
if (runtimeErrors.length === 0) return
|
||||
const runtimeErrors = card.errors
|
||||
.map((error, idx) => ({ error, idx }))
|
||||
.filter(({ error }) => error.isRuntimeError)
|
||||
|
||||
if (!systemStatsStore.systemStats) {
|
||||
if (systemStatsStore.isLoading) {
|
||||
await until(systemStatsStore.isLoading).toBe(false)
|
||||
} else {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch system stats for error report:', e)
|
||||
return
|
||||
if (runtimeErrors.length === 0) return
|
||||
|
||||
if (!systemStatsStore.systemStats) {
|
||||
if (systemStatsStore.isLoading) {
|
||||
await until(() => systemStatsStore.isLoading).toBe(false)
|
||||
} else {
|
||||
try {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch system stats for error report:', e)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!systemStatsStore.systemStats || cancelled) return
|
||||
if (!systemStatsStore.systemStats || cancelled) return
|
||||
|
||||
const logs = await api
|
||||
.getLogs()
|
||||
.catch(() => 'Failed to retrieve server logs')
|
||||
if (cancelled) return
|
||||
const logs = await api
|
||||
.getLogs()
|
||||
.catch(() => 'Failed to retrieve server logs')
|
||||
if (cancelled) return
|
||||
|
||||
const workflow = (() => {
|
||||
try {
|
||||
return app.rootGraph.serialize()
|
||||
} catch (e) {
|
||||
console.warn('Failed to serialize workflow for error report:', e)
|
||||
return null
|
||||
const workflow = (() => {
|
||||
try {
|
||||
return app.rootGraph.serialize()
|
||||
} catch (e) {
|
||||
console.warn('Failed to serialize workflow for error report:', e)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!workflow) return
|
||||
|
||||
for (const { error, idx } of runtimeErrors) {
|
||||
try {
|
||||
const report = generateErrorReport({
|
||||
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
|
||||
exceptionMessage: error.message,
|
||||
traceback: error.details,
|
||||
nodeId: card.nodeId,
|
||||
nodeType: card.title,
|
||||
systemStats: systemStatsStore.systemStats,
|
||||
serverLogs: logs,
|
||||
workflow
|
||||
})
|
||||
enrichedDetails[idx] = report
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate error report:', e)
|
||||
}
|
||||
}
|
||||
})()
|
||||
if (!workflow) return
|
||||
|
||||
for (const { error, idx } of runtimeErrors) {
|
||||
try {
|
||||
const report = generateErrorReport({
|
||||
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
|
||||
exceptionMessage: error.message,
|
||||
traceback: error.details,
|
||||
nodeId: card.nodeId,
|
||||
nodeType: card.title,
|
||||
systemStats: systemStatsStore.systemStats,
|
||||
serverLogs: logs,
|
||||
workflow
|
||||
})
|
||||
enrichedDetails[idx] = report
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate error report:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { displayedDetailsMap }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -79,7 +78,6 @@ function isWidgetShownOnParents(
|
||||
): boolean {
|
||||
return parents.some((parent) => {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const sourceNodeId = getSourceNodeId(widget)
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? widget.sourceNodeId
|
||||
@@ -88,7 +86,7 @@ function isWidgetShownOnParents(
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: sourceNodeId
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
|
||||
@@ -14,10 +14,7 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getSourceNodeId,
|
||||
getWidgetName
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
@@ -132,7 +129,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(widget)
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -98,7 +98,8 @@ describe('WidgetActions', () => {
|
||||
type: 'TestNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [200, 100]
|
||||
size: [200, 100],
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,7 +226,8 @@ describe('WidgetActions', () => {
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' }
|
||||
rootGraph: { id: 'graph-test' },
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
const widget = {
|
||||
name: 'text',
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
getSourceNodeId,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -47,6 +47,11 @@ const promotionStore = usePromotionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
|
||||
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
@@ -76,8 +81,6 @@ function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
const disambiguatingSourceNodeId = getSourceNodeId(widget)
|
||||
|
||||
for (const parent of parents) {
|
||||
const source: PromotedWidgetSource = {
|
||||
sourceNodeId:
|
||||
@@ -85,7 +88,7 @@ function handleHideInput() {
|
||||
? widget.sourceNodeId
|
||||
: String(node.id),
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
}
|
||||
promotionStore.demote(parent.rootGraph.id, parent.id, source)
|
||||
parent.computeSize(parent.size)
|
||||
@@ -114,6 +117,7 @@ function handleResetToDefault() {
|
||||
<template>
|
||||
<MoreButton
|
||||
is-vertical
|
||||
data-testid="widget-actions-menu-button"
|
||||
class="bg-transparent text-muted-foreground transition-all hover:bg-secondary-background-hover hover:text-base-foreground active:scale-95"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
@@ -133,7 +137,7 @@ function handleResetToDefault() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasParents"
|
||||
v-if="canToggleVisibility"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected
|
||||
@@ -88,14 +89,13 @@ const activeWidgets = computed<WidgetItem[]>({
|
||||
promotionStore.setPromotions(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
value.map(([n, w]) => {
|
||||
const sid = getSourceNodeId(w)
|
||||
return {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
...(sid && { disambiguatingSourceNodeId: sid })
|
||||
}
|
||||
})
|
||||
value.map(([n, w]) => ({
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
@@ -123,7 +123,9 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
|
||||
sourceNodeId: String(n.id),
|
||||
sourceWidgetName: getWidgetName(w),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(w)
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(w)
|
||||
? w.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -162,6 +164,18 @@ function refreshPromotedWidgetRendering() {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function isItemLinked([node, widget]: WidgetItem): boolean {
|
||||
return (
|
||||
node.id === -1 ||
|
||||
(!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(node.id),
|
||||
getWidgetName(widget)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
function toKey(item: WidgetItem) {
|
||||
const sid = getSourceNodeId(item[1])
|
||||
return sid
|
||||
@@ -187,8 +201,14 @@ function showAll() {
|
||||
}
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
for (const item of filteredActive.value) {
|
||||
if (String(item[0].id) === '-1') continue
|
||||
if (
|
||||
node &&
|
||||
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
|
||||
)
|
||||
continue
|
||||
demote(item)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +243,7 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredActive.length"
|
||||
data-testid="subgraph-editor-shown-section"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
@@ -244,8 +265,8 @@ onMounted(() => {
|
||||
:key="toKey([node, widget])"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
:is-physical="node.id === -1"
|
||||
:widget-name="widget.label || widget.name"
|
||||
:is-physical="isItemLinked([node, widget])"
|
||||
:is-draggable="!searchQuery"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
@@ -254,6 +275,7 @@ onMounted(() => {
|
||||
|
||||
<div
|
||||
v-if="filteredCandidates.length"
|
||||
data-testid="subgraph-editor-hidden-section"
|
||||
class="flex flex-col border-b border-interface-stroke"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
nodeTitle,
|
||||
widgetName,
|
||||
isDraggable = false,
|
||||
isPhysical = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
nodeTitle: string
|
||||
widgetName: string
|
||||
isDraggable?: boolean
|
||||
@@ -14,13 +22,13 @@ defineEmits<{
|
||||
(e: 'toggleVisibility'): void
|
||||
}>()
|
||||
|
||||
function getIcon() {
|
||||
return props.isPhysical
|
||||
const icon = computed(() =>
|
||||
isPhysical
|
||||
? 'icon-[lucide--link]'
|
||||
: props.isDraggable
|
||||
: isDraggable
|
||||
? 'icon-[lucide--eye]'
|
||||
: 'icon-[lucide--eye-off]'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,8 +37,8 @@ function getIcon() {
|
||||
cn(
|
||||
'flex items-center gap-1 rounded-sm px-2 py-1 break-all',
|
||||
'bg-node-component-surface',
|
||||
props.isDraggable && 'ring-accent-background hover:ring-1',
|
||||
props.class
|
||||
isDraggable && 'ring-accent-background hover:ring-1',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -38,15 +46,18 @@ function getIcon() {
|
||||
<div class="line-clamp-1 text-xs text-text-secondary">
|
||||
{{ nodeTitle }}
|
||||
</div>
|
||||
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
|
||||
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
|
||||
{{ widgetName }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
data-testid="subgraph-widget-toggle"
|
||||
:disabled="isPhysical"
|
||||
@click.stop="$emit('toggleVisibility')"
|
||||
>
|
||||
<i :class="getIcon()" />
|
||||
<i :class="icon" :data-testid="isPhysical ? 'icon-link' : 'icon-eye'" />
|
||||
</Button>
|
||||
<div
|
||||
v-if="isDraggable"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
@@ -32,7 +33,8 @@ function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
|
||||
function reconcileNodeErrorFlags(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError> | null,
|
||||
missingModelExecIds: Set<string>
|
||||
missingModelExecIds: Set<string>,
|
||||
missingMediaExecIds: Set<string> = new Set()
|
||||
): void {
|
||||
// Collect nodes and slot info that should be flagged
|
||||
// Includes both error-owning nodes and their ancestor containers
|
||||
@@ -64,6 +66,11 @@ function reconcileNodeErrorFlags(
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
for (const execId of missingMediaExecIds) {
|
||||
const node = getNodeByExecutionId(rootGraph, execId)
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
forEachNode(rootGraph, (node) => {
|
||||
setNodeHasErrors(node, flaggedNodes.has(node))
|
||||
|
||||
@@ -78,7 +85,8 @@ function reconcileNodeErrorFlags(
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
||||
): () => void {
|
||||
const settingStore = useSettingStore()
|
||||
const showErrorsTab = computed(() =>
|
||||
@@ -89,12 +97,13 @@ export function useNodeErrorFlagSync(
|
||||
[
|
||||
lastNodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
() => missingMediaStore.missingMediaNodeIds,
|
||||
showErrorsTab
|
||||
],
|
||||
() => {
|
||||
if (!app.isGraphReady) return
|
||||
// Legacy (LGraphNode) only: suppress missing-model error flags when
|
||||
// the Errors tab is hidden, since legacy nodes lack the per-widget
|
||||
// Legacy (LGraphNode) only: suppress missing-model/media error flags
|
||||
// when the Errors tab is hidden, since legacy nodes lack the per-widget
|
||||
// red highlight that Vue nodes use to indicate *why* a node has errors.
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
@@ -102,6 +111,9 @@ export function useNodeErrorFlagSync(
|
||||
lastNodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set(),
|
||||
showErrorsTab.value
|
||||
? missingMediaStore.missingMediaAncestorExecutionIds
|
||||
: new Set()
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -31,12 +31,11 @@ describe(matchPromotedInput, () => {
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
fromAny<
|
||||
fromPartial<
|
||||
Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
unknown
|
||||
}>
|
||||
>([aliasInput, exactInput]),
|
||||
targetWidget
|
||||
)
|
||||
@@ -51,9 +50,7 @@ describe(matchPromotedInput, () => {
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
fromAny<Array<{ name: string; _widget?: IBaseWidget }>, unknown>([
|
||||
aliasInput
|
||||
]),
|
||||
fromPartial<Array<{ name: string; _widget?: IBaseWidget }>>([aliasInput]),
|
||||
targetWidget
|
||||
)
|
||||
|
||||
@@ -70,12 +67,11 @@ describe(matchPromotedInput, () => {
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
fromAny<
|
||||
fromPartial<
|
||||
Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
unknown
|
||||
}>
|
||||
>([firstAliasInput, secondAliasInput]),
|
||||
targetWidget
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
@@ -293,7 +293,7 @@ describe(createPromotedWidgetView, () => {
|
||||
value: 'initial',
|
||||
options: {}
|
||||
} satisfies Pick<IBaseWidget, 'name' | 'type' | 'value' | 'options'>
|
||||
const fallbackWidget = fromAny<IBaseWidget, unknown>(fallbackWidgetShape)
|
||||
const fallbackWidget = fromPartial<IBaseWidget>(fallbackWidgetShape)
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected
|
||||
@@ -30,7 +31,7 @@ function widget(
|
||||
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
|
||||
>
|
||||
): IBaseWidget {
|
||||
return fromAny<IBaseWidget, unknown>({ name: 'widget', ...overrides })
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
@@ -334,3 +335,84 @@ describe('hasUnpromotedWidgets', () => {
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLinkedPromotion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function linkedWidget(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...extra
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function createSubgraphWithInputs(count = 1) {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: Array.from({ length: count }, (_, i) => ({
|
||||
name: `input_${i}`,
|
||||
type: 'STRING' as const
|
||||
}))
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
it('returns true when an input has a matching _widget', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no inputs exist or none match', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when _widget is undefined on input', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
|
||||
const subgraphNode = createSubgraphWithInputs()
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies multiple linked widgets across different inputs', () => {
|
||||
const subgraphNode = createSubgraphWithInputs(2)
|
||||
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
|
||||
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
|
||||
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,27 @@ export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given promotion entry corresponds to a linked promotion
|
||||
* on the subgraph node. Linked promotions are driven by subgraph input
|
||||
* connections and cannot be independently hidden or shown.
|
||||
*/
|
||||
export function isLinkedPromotion(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
): boolean {
|
||||
return subgraphNode.inputs.some((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === sourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function getSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
if (!isPromotedWidgetView(w)) return undefined
|
||||
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
|
||||
@@ -39,7 +60,9 @@ function toPromotionSource(
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: getSourceNodeId(widget)
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3528,6 +3528,23 @@
|
||||
"missingModelsTitle": "Missing Models",
|
||||
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
|
||||
"downloadAll": "Download all"
|
||||
},
|
||||
"missingMedia": {
|
||||
"missingMediaTitle": "Missing Inputs",
|
||||
"image": "Images",
|
||||
"video": "Videos",
|
||||
"audio": "Audio",
|
||||
"locateNode": "Locate node",
|
||||
"expandNodes": "Show referencing nodes",
|
||||
"collapseNodes": "Hide referencing nodes",
|
||||
"uploadFile": "Upload {type}",
|
||||
"uploading": "Uploading...",
|
||||
"uploaded": "Uploaded",
|
||||
"selectedFromLibrary": "Selected from library",
|
||||
"useFromLibrary": "Use from Library",
|
||||
"confirmSelection": "Confirm selection",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"or": "OR"
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
|
||||
61
src/platform/missingMedia/components/MissingMediaCard.vue
Normal file
61
src/platform/missingMedia/components/MissingMediaCard.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div
|
||||
v-for="group in missingMediaGroups"
|
||||
:key="group.mediaType"
|
||||
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<!-- Media type header -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="MEDIA_TYPE_ICONS[group.mediaType]"
|
||||
class="mr-1 size-3.5 align-text-bottom"
|
||||
/>
|
||||
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
|
||||
({{ group.items.length }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media file rows -->
|
||||
<div class="flex flex-col gap-1 overflow-hidden pl-2">
|
||||
<MissingMediaRow
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
:item="item"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
|
||||
|
||||
const { missingMediaGroups } = defineProps<{
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
|
||||
image: 'icon-[lucide--image]',
|
||||
video: 'icon-[lucide--video]',
|
||||
audio: 'icon-[lucide--music]'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,147 @@
|
||||
<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.missingMedia.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.missingMedia.useFromLibrary')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent class="max-h-72">
|
||||
<template v-if="options.length > SEARCH_THRESHOLD" #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"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="getPreviewUrl(option.value)"
|
||||
alt=""
|
||||
class="size-8 shrink-0 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<video
|
||||
v-else-if="mediaType === 'video'"
|
||||
aria-hidden="true"
|
||||
:src="getPreviewUrl(option.value)"
|
||||
class="size-8 shrink-0 rounded-sm object-cover"
|
||||
preload="metadata"
|
||||
muted
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ option.name }}</span>
|
||||
</div>
|
||||
</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'
|
||||
import type { MediaType } from '@/platform/missingMedia/types'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const {
|
||||
options,
|
||||
showDivider = false,
|
||||
mediaType
|
||||
} = defineProps<{
|
||||
modelValue: string | undefined
|
||||
options: { name: string; value: string }[]
|
||||
showDivider?: boolean
|
||||
mediaType: MediaType
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const SEARCH_THRESHOLD = 4
|
||||
const filterQuery = ref('')
|
||||
|
||||
watch(
|
||||
() => options.length,
|
||||
(len) => {
|
||||
if (len <= SEARCH_THRESHOLD) 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 getPreviewUrl(filename: string): string {
|
||||
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
|
||||
}
|
||||
|
||||
function handleSelect(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
filterQuery.value = ''
|
||||
emit('select', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
318
src/platform/missingMedia/components/MissingMediaRow.vue
Normal file
318
src/platform/missingMedia/components/MissingMediaRow.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
|
||||
<!-- File header -->
|
||||
<div class="flex h-8 w-full items-center gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground icon-[lucide--file] size-4 shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Single node: show node display name instead of filename -->
|
||||
<template v-if="isSingleNode">
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ item.referencingNodes[0].nodeId }}
|
||||
</span>
|
||||
<p
|
||||
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:title="singleNodeLabel"
|
||||
>
|
||||
{{ singleNodeLabel }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Multiple nodes: show filename with count -->
|
||||
<p
|
||||
v-else
|
||||
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:title="displayName"
|
||||
>
|
||||
{{ displayName }}
|
||||
({{ item.referencingNodes.length }})
|
||||
</p>
|
||||
|
||||
<!-- Confirm button (visible when pending selection exists) -->
|
||||
<Button
|
||||
data-testid="missing-media-confirm-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
|
||||
:disabled="!isPending"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 rounded-lg transition-colors',
|
||||
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
|
||||
)
|
||||
"
|
||||
@click="confirmSelection(item.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="isPending ? 'text-primary' : 'text-foreground'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<!-- Locate button (single node only) -->
|
||||
<Button
|
||||
v-if="isSingleNode"
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
|
||||
<!-- Expand button (multiple nodes only) -->
|
||||
<Button
|
||||
v-if="!isSingleNode"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingMedia.collapseNodes')
|
||||
: t('rightSidePanel.missingMedia.expandNodes')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-180'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand(item.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Referencing nodes (expandable) -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded && item.referencingNodes.length > 1"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
>
|
||||
<div
|
||||
v-for="nodeRef in item.referencingNodes"
|
||||
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
#{{ nodeRef.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
|
||||
</p>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateNode', String(nodeRef.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Status card (uploading, uploaded, or library select) -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="isPending || isUploading"
|
||||
data-testid="missing-media-status-card"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<i
|
||||
v-if="currentUpload?.status === 'uploading'"
|
||||
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">
|
||||
{{ pendingDisplayName }}
|
||||
</span>
|
||||
<span class="mt-0.5 text-xs/tight text-muted-foreground">
|
||||
<template v-if="currentUpload?.status === 'uploading'">
|
||||
{{ t('rightSidePanel.missingMedia.uploading') }}
|
||||
</template>
|
||||
<template v-else-if="currentUpload?.status === 'uploaded'">
|
||||
{{ t('rightSidePanel.missingMedia.uploaded') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
data-testid="missing-media-cancel-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
|
||||
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="cancelSelection(item.name)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Upload + Library (when no pending selection) -->
|
||||
<TransitionCollapse>
|
||||
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
|
||||
<!-- Upload dropzone -->
|
||||
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
|
||||
<button
|
||||
data-testid="missing-media-upload-dropzone"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
|
||||
isOverDropZone && 'border-primary text-primary'
|
||||
)
|
||||
"
|
||||
@click="openFilePicker()"
|
||||
>
|
||||
{{
|
||||
t('rightSidePanel.missingMedia.uploadFile', {
|
||||
type: extensionHint
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OR separator + Use from Library -->
|
||||
<MissingMediaLibrarySelect
|
||||
data-testid="missing-media-library-select"
|
||||
:model-value="undefined"
|
||||
:options="libraryOptions"
|
||||
:show-divider="true"
|
||||
:media-type="item.mediaType"
|
||||
@select="handleLibrarySelect(item.name, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDropZone, useFileDialog } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
|
||||
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import {
|
||||
useMissingMediaInteractions,
|
||||
getNodeDisplayLabel,
|
||||
getMediaDisplayName
|
||||
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
|
||||
|
||||
const { item, showNodeIdBadge } = defineProps<{
|
||||
item: MissingMediaViewModel
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useMissingMediaStore()
|
||||
const { uploadState, pendingSelection } = storeToRefs(store)
|
||||
|
||||
const {
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
getAcceptType,
|
||||
getExtensionHint,
|
||||
getLibraryOptions,
|
||||
handleLibrarySelect,
|
||||
handleUpload,
|
||||
confirmSelection,
|
||||
cancelSelection,
|
||||
hasPendingSelection
|
||||
} = useMissingMediaInteractions()
|
||||
|
||||
const displayName = getMediaDisplayName(item.name)
|
||||
const isSingleNode = item.referencingNodes.length === 1
|
||||
const singleNodeLabel = isSingleNode
|
||||
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
|
||||
: ''
|
||||
const acceptType = getAcceptType(item.mediaType)
|
||||
const extensionHint = getExtensionHint(item.mediaType)
|
||||
|
||||
const expanded = computed(() => isExpanded(item.name))
|
||||
const matchingCandidate = computed(() => {
|
||||
const candidates = store.missingMediaCandidates
|
||||
if (!candidates?.length) return null
|
||||
return candidates.find((c) => c.name === item.name) ?? null
|
||||
})
|
||||
const libraryOptions = computed(() => {
|
||||
const candidate = matchingCandidate.value
|
||||
if (!candidate) return []
|
||||
return getLibraryOptions(candidate)
|
||||
})
|
||||
|
||||
const isPending = computed(() => hasPendingSelection(item.name))
|
||||
const isUploading = computed(
|
||||
() => uploadState.value[item.name]?.status === 'uploading'
|
||||
)
|
||||
const currentUpload = computed(() => uploadState.value[item.name])
|
||||
const pendingDisplayName = computed(() => {
|
||||
if (currentUpload.value) return currentUpload.value.fileName
|
||||
const pending = pendingSelection.value[item.name]
|
||||
return pending ? getMediaDisplayName(pending) : ''
|
||||
})
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
onDrop: (_files, event) => {
|
||||
event?.stopPropagation()
|
||||
const file = _files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file, item.name, item.mediaType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
|
||||
accept: acceptType,
|
||||
multiple: false
|
||||
})
|
||||
onFileSelected((files) => {
|
||||
const file = files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file, item.name, item.mediaType)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,224 @@
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type {
|
||||
MissingMediaCandidate,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
ACCEPTED_VIDEO_TYPES
|
||||
} from '@/utils/mediaUploadUtil'
|
||||
|
||||
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
|
||||
image: ACCEPTED_IMAGE_TYPES,
|
||||
video: ACCEPTED_VIDEO_TYPES,
|
||||
audio: 'audio/*'
|
||||
}
|
||||
|
||||
function getMediaComboWidget(
|
||||
candidate: MissingMediaCandidate
|
||||
): { node: LGraphNode; widget: IComboWidget } | null {
|
||||
const graph = app.rootGraph
|
||||
if (!graph || candidate.nodeId == null) return null
|
||||
|
||||
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
|
||||
if (!node) return null
|
||||
|
||||
const widget = node.widgets?.find(
|
||||
(w) => w.name === candidate.widgetName && w.type === 'combo'
|
||||
) as IComboWidget | undefined
|
||||
if (!widget) return null
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
function resolveLibraryOptions(
|
||||
candidate: MissingMediaCandidate
|
||||
): { name: string; value: string }[] {
|
||||
const result = getMediaComboWidget(candidate)
|
||||
if (!result) return []
|
||||
|
||||
return resolveComboValues(result.widget)
|
||||
.filter((v) => v !== candidate.name)
|
||||
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
|
||||
}
|
||||
|
||||
function applyValueToNodes(
|
||||
candidates: MissingMediaCandidate[],
|
||||
name: string,
|
||||
newValue: string
|
||||
) {
|
||||
const matching = candidates.filter((c) => c.name === name)
|
||||
for (const c of matching) {
|
||||
const result = getMediaComboWidget(c)
|
||||
if (!result) continue
|
||||
|
||||
addToComboValues(result.widget, newValue)
|
||||
result.widget.value = newValue
|
||||
result.widget.callback?.(newValue)
|
||||
result.node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeDisplayLabel(
|
||||
nodeId: string | number,
|
||||
fallback: string
|
||||
): string {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return fallback
|
||||
const node = getNodeByExecutionId(graph, String(nodeId))
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallback,
|
||||
untitledLabel: fallback,
|
||||
st
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve display name for a media file.
|
||||
* Cloud widgets store asset hashes as values; this resolves them to
|
||||
* human-readable names via assetsStore.getInputName().
|
||||
*/
|
||||
export function getMediaDisplayName(name: string): string {
|
||||
if (!isCloud) return name
|
||||
return useAssetsStore().getInputName(name)
|
||||
}
|
||||
|
||||
export function useMissingMediaInteractions() {
|
||||
const store = useMissingMediaStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
function isExpanded(key: string): boolean {
|
||||
return store.expandState[key] ?? false
|
||||
}
|
||||
|
||||
function toggleExpand(key: string) {
|
||||
store.expandState[key] = !isExpanded(key)
|
||||
}
|
||||
|
||||
function getAcceptType(mediaType: MediaType): string {
|
||||
return MEDIA_ACCEPT_MAP[mediaType]
|
||||
}
|
||||
|
||||
function getExtensionHint(mediaType: MediaType): string {
|
||||
if (mediaType === 'audio') return 'audio'
|
||||
const exts = MEDIA_ACCEPT_MAP[mediaType]
|
||||
.split(',')
|
||||
.map((mime) => mime.split('/')[1])
|
||||
.join(', ')
|
||||
return `${exts}, ...`
|
||||
}
|
||||
|
||||
function getLibraryOptions(
|
||||
candidate: MissingMediaCandidate
|
||||
): { name: string; value: string }[] {
|
||||
return resolveLibraryOptions(candidate)
|
||||
}
|
||||
|
||||
/** Step 1: Store selection from library (does not apply yet). */
|
||||
function handleLibrarySelect(name: string, value: string) {
|
||||
store.pendingSelection[name] = value
|
||||
}
|
||||
|
||||
/** Step 1: Upload file and store result as pending (does not apply yet). */
|
||||
async function handleUpload(file: File, name: string, mediaType: MediaType) {
|
||||
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.unsupportedFileType',
|
||||
'Unsupported file type. Please select a valid file.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.uploadFailed',
|
||||
'Failed to upload file. Please try again.'
|
||||
)
|
||||
)
|
||||
delete store.uploadState[name]
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
const uploadedPath: string = data.subfolder
|
||||
? `${data.subfolder}/${data.name}`
|
||||
: data.name
|
||||
|
||||
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
|
||||
store.pendingSelection[name] = uploadedPath
|
||||
|
||||
// Refresh assets store (non-critical — upload already succeeded)
|
||||
try {
|
||||
await assetsStore.updateInputs()
|
||||
} catch {
|
||||
// Asset list refresh failed but upload is valid; selection can proceed
|
||||
}
|
||||
} catch {
|
||||
useToastStore().addAlert(
|
||||
st(
|
||||
'toastMessages.uploadFailed',
|
||||
'Failed to upload file. Please try again.'
|
||||
)
|
||||
)
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: Apply pending selection to widgets and remove from missing list. */
|
||||
function confirmSelection(name: string) {
|
||||
const value = store.pendingSelection[name]
|
||||
if (!value || !store.missingMediaCandidates) return
|
||||
|
||||
applyValueToNodes(store.missingMediaCandidates, name, value)
|
||||
store.removeMissingMediaByName(name)
|
||||
delete store.pendingSelection[name]
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
|
||||
function cancelSelection(name: string) {
|
||||
delete store.pendingSelection[name]
|
||||
delete store.uploadState[name]
|
||||
}
|
||||
|
||||
function hasPendingSelection(name: string): boolean {
|
||||
return name in store.pendingSelection
|
||||
}
|
||||
|
||||
return {
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
getAcceptType,
|
||||
getExtensionHint,
|
||||
getLibraryOptions,
|
||||
handleLibrarySelect,
|
||||
handleUpload,
|
||||
confirmSelection,
|
||||
cancelSelection,
|
||||
hasPendingSelection
|
||||
}
|
||||
}
|
||||
207
src/platform/missingMedia/missingMediaScan.test.ts
Normal file
207
src/platform/missingMedia/missingMediaScan.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
verifyCloudMediaCandidates,
|
||||
groupCandidatesByName,
|
||||
groupCandidatesByMediaType
|
||||
} from './missingMediaScan'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
function makeCandidate(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
overrides: Partial<MissingMediaCandidate> = {}
|
||||
): MissingMediaCandidate {
|
||||
return {
|
||||
nodeId,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name,
|
||||
isMissing: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupCandidatesByName', () => {
|
||||
it('groups candidates with the same name', () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'photo.png'),
|
||||
makeCandidate('3', 'other.png')
|
||||
]
|
||||
|
||||
const result = groupCandidatesByName(candidates)
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
const photoGroup = result.find((g) => g.name === 'photo.png')
|
||||
expect(photoGroup?.referencingNodes).toHaveLength(2)
|
||||
expect(photoGroup?.mediaType).toBe('image')
|
||||
|
||||
const otherGroup = result.find((g) => g.name === 'other.png')
|
||||
expect(otherGroup?.referencingNodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(groupCandidatesByName([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupCandidatesByMediaType', () => {
|
||||
it('groups by media type in order: image, video, audio', () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'sound.mp3', {
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
mediaType: 'audio'
|
||||
}),
|
||||
makeCandidate('2', 'photo.png'),
|
||||
makeCandidate('3', 'clip.mp4', {
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video'
|
||||
})
|
||||
]
|
||||
|
||||
const result = groupCandidatesByMediaType(candidates)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].mediaType).toBe('image')
|
||||
expect(result[1].mediaType).toBe('video')
|
||||
expect(result[2].mediaType).toBe('audio')
|
||||
})
|
||||
|
||||
it('omits media types with no candidates', () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'clip.mp4', {
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video'
|
||||
})
|
||||
]
|
||||
|
||||
const result = groupCandidatesByMediaType(candidates)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('video')
|
||||
})
|
||||
|
||||
it('groups multiple names within the same media type', () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'a.png'),
|
||||
makeCandidate('2', 'b.png'),
|
||||
makeCandidate('3', 'a.png')
|
||||
]
|
||||
|
||||
const result = groupCandidatesByMediaType(candidates)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('image')
|
||||
expect(result[0].items).toHaveLength(2)
|
||||
expect(
|
||||
result[0].items.find((i) => i.name === 'a.png')?.referencingNodes
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyCloudMediaCandidates', () => {
|
||||
it('marks candidates missing when not in input assets', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'abc123.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'def456.png', { isMissing: undefined })
|
||||
]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: [{ asset_hash: 'def456.png', name: 'my-photo.png' }]
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('calls updateInputs before checking assets', async () => {
|
||||
let updateCalled = false
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
updateCalled = true
|
||||
},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(updateCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('respects abort signal before execution', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const candidates = [
|
||||
makeCandidate('1', 'abc123.png', { isMissing: undefined })
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('respects abort signal after updateInputs', async () => {
|
||||
const controller = new AbortController()
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
controller.abort()
|
||||
},
|
||||
inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }]
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal, mockStore)
|
||||
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips candidates already resolved as true', async () => {
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('skips candidates already resolved as false', async () => {
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('skips entirely when no pending candidates', async () => {
|
||||
let updateCalled = false
|
||||
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {
|
||||
updateCalled = true
|
||||
},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(updateCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
159
src/platform/missingMedia/missingMediaScan.ts
Normal file
159
src/platform/missingMedia/missingMediaScan.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { groupBy } from 'es-toolkit'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
MissingMediaCandidate,
|
||||
MissingMediaViewModel,
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from './types'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
const MEDIA_NODE_WIDGETS: Record<
|
||||
string,
|
||||
{ widgetName: string; mediaType: MediaType }
|
||||
> = {
|
||||
LoadImage: { widgetName: 'image', mediaType: 'image' },
|
||||
LoadVideo: { widgetName: 'file', mediaType: 'video' },
|
||||
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
|
||||
}
|
||||
|
||||
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan combo widgets on media nodes for file values that may be missing.
|
||||
*
|
||||
* OSS: `isMissing` resolved immediately via widget options.
|
||||
* Cloud: `isMissing` left `undefined` for async verification.
|
||||
*/
|
||||
export function scanAllMediaCandidates(
|
||||
rootGraph: LGraph,
|
||||
isCloud: boolean
|
||||
): MissingMediaCandidate[] {
|
||||
if (!rootGraph) return []
|
||||
|
||||
const allNodes = collectAllNodes(rootGraph)
|
||||
const candidates: MissingMediaCandidate[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (!node.widgets?.length) continue
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
|
||||
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
|
||||
if (!mediaInfo) continue
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) continue
|
||||
|
||||
for (const widget of node.widgets) {
|
||||
if (!isComboWidget(widget)) continue
|
||||
if (widget.name !== mediaInfo.widgetName) continue
|
||||
|
||||
const value = widget.value
|
||||
if (typeof value !== 'string' || !value.trim()) continue
|
||||
|
||||
let isMissing: boolean | undefined
|
||||
if (isCloud) {
|
||||
// Cloud: options may be empty initially; defer to async verification
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const options = resolveComboValues(widget)
|
||||
isMissing = !options.includes(value)
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
mediaType: mediaInfo.mediaType,
|
||||
name: value,
|
||||
isMissing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
interface InputVerifier {
|
||||
updateInputs: () => Promise<unknown>
|
||||
inputAssets: Array<{ asset_hash?: string | null; name: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cloud media candidates against the input assets fetched from the
|
||||
* assets store. Mutates candidates' `isMissing` in place.
|
||||
*/
|
||||
export async function verifyCloudMediaCandidates(
|
||||
candidates: MissingMediaCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: InputVerifier
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
const store =
|
||||
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
||||
|
||||
await store.updateInputs()
|
||||
|
||||
if (signal?.aborted) return
|
||||
|
||||
const assetHashes = new Set(
|
||||
store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
)
|
||||
|
||||
for (const c of pending) {
|
||||
c.isMissing = !assetHashes.has(c.name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by file name into view models. */
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingMediaCandidate[]
|
||||
): MissingMediaViewModel[] {
|
||||
const map = new Map<string, MissingMediaViewModel>()
|
||||
for (const c of candidates) {
|
||||
const existing = map.get(c.name)
|
||||
if (existing) {
|
||||
existing.referencingNodes.push({
|
||||
nodeId: c.nodeId,
|
||||
widgetName: c.widgetName
|
||||
})
|
||||
} else {
|
||||
map.set(c.name, {
|
||||
name: c.name,
|
||||
mediaType: c.mediaType,
|
||||
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by media type. */
|
||||
export function groupCandidatesByMediaType(
|
||||
candidates: MissingMediaCandidate[]
|
||||
): MissingMediaGroup[] {
|
||||
const grouped = groupBy(candidates, (c) => c.mediaType)
|
||||
const order: MediaType[] = ['image', 'video', 'audio']
|
||||
return order
|
||||
.filter((t) => t in grouped)
|
||||
.map((mediaType) => ({
|
||||
mediaType,
|
||||
items: groupCandidatesByName(grouped[mediaType])
|
||||
}))
|
||||
}
|
||||
197
src/platform/missingMedia/missingMediaStore.test.ts
Normal file
197
src/platform/missingMedia/missingMediaStore.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useMissingMediaStore } from './missingMediaStore'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
currentGraph: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getActiveGraphNodeIds: () => new Set<string>()
|
||||
}))
|
||||
|
||||
function makeCandidate(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
mediaType: 'image' | 'video' | 'audio' = 'image'
|
||||
): MissingMediaCandidate {
|
||||
return {
|
||||
nodeId,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType,
|
||||
name,
|
||||
isMissing: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMissingMediaStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with no missing media', () => {
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
expect(store.missingMediaCount).toBe(0)
|
||||
})
|
||||
|
||||
it('setMissingMedia populates candidates', () => {
|
||||
const store = useMissingMediaStore()
|
||||
const candidates = [makeCandidate('1', 'photo.png')]
|
||||
|
||||
store.setMissingMedia(candidates)
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.hasMissingMedia).toBe(true)
|
||||
expect(store.missingMediaCount).toBe(1)
|
||||
})
|
||||
|
||||
it('setMissingMedia with empty array clears state', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.setMissingMedia([])
|
||||
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('clearMissingMedia resets all state including interaction state', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
const controller = store.createVerificationAbortController()
|
||||
|
||||
store.clearMissingMedia()
|
||||
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
expect(store.missingMediaCount).toBe(0)
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
expect(store.expandState).toEqual({})
|
||||
expect(store.uploadState).toEqual({})
|
||||
expect(store.pendingSelection).toEqual({})
|
||||
})
|
||||
|
||||
it('missingMediaNodeIds tracks unique node IDs', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('1', 'other.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
|
||||
expect(store.missingMediaNodeIds.size).toBe(2)
|
||||
expect(store.missingMediaNodeIds.has('1')).toBe(true)
|
||||
expect(store.missingMediaNodeIds.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('hasMissingMediaOnNode checks node presence', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('42', 'photo.png')])
|
||||
|
||||
expect(store.hasMissingMediaOnNode('42')).toBe(true)
|
||||
expect(store.hasMissingMediaOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget removes matching node+widget entry', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].name).toBe('clip.mp4')
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget nulls candidates when last entry removed', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget ignores non-matching entries', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
|
||||
store.removeMissingMediaByWidget('99', 'image')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removeMissingMediaByName clears interaction state for removed name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
|
||||
store.removeMissingMediaByName('photo.png')
|
||||
|
||||
expect(store.expandState['photo.png']).toBeUndefined()
|
||||
expect(store.uploadState['photo.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'photo.png')
|
||||
])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByWidget('1', 'image')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
|
||||
})
|
||||
|
||||
it('createVerificationAbortController aborts previous controller', () => {
|
||||
const store = useMissingMediaStore()
|
||||
const first = store.createVerificationAbortController()
|
||||
expect(first.signal.aborted).toBe(false)
|
||||
|
||||
store.createVerificationAbortController()
|
||||
expect(first.signal.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
154
src/platform/missingMedia/missingMediaStore.ts
Normal file
154
src/platform/missingMedia/missingMediaStore.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
* Missing media error state.
|
||||
* Separated from executionErrorStore to keep domain boundaries clean.
|
||||
* The executionErrorStore composes from this store for aggregate error flags.
|
||||
*/
|
||||
export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const missingMediaCandidates = ref<MissingMediaCandidate[] | null>(null)
|
||||
|
||||
const hasMissingMedia = computed(() => !!missingMediaCandidates.value?.length)
|
||||
|
||||
const missingMediaCount = computed(
|
||||
() => missingMediaCandidates.value?.length ?? 0
|
||||
)
|
||||
|
||||
const missingMediaNodeIds = computed(
|
||||
() =>
|
||||
new Set(missingMediaCandidates.value?.map((m) => String(m.nodeId)) ?? [])
|
||||
)
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing media node IDs,
|
||||
* including the missing media nodes themselves.
|
||||
*/
|
||||
const missingMediaAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingMediaNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
)
|
||||
|
||||
const activeMissingMediaGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingMediaAncestorExecutionIds.value
|
||||
)
|
||||
})
|
||||
|
||||
// Interaction state — persists across component re-mounts
|
||||
const expandState = ref<Record<string, boolean>>({})
|
||||
const uploadState = ref<
|
||||
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
|
||||
>({})
|
||||
/** Pending selection: value to apply on confirm. */
|
||||
const pendingSelection = ref<Record<string, string>>({})
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
|
||||
function setMissingMedia(media: MissingMediaCandidate[]) {
|
||||
missingMediaCandidates.value = media.length ? media : null
|
||||
}
|
||||
|
||||
function hasMissingMediaOnNode(nodeLocatorId: string): boolean {
|
||||
return missingMediaNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
|
||||
function isContainerWithMissingMedia(node: LGraphNode): boolean {
|
||||
return activeMissingMediaGraphIds.value.has(String(node.id))
|
||||
}
|
||||
|
||||
function clearInteractionStateForName(name: string) {
|
||||
delete expandState.value[name]
|
||||
delete uploadState.value[name]
|
||||
delete pendingSelection.value[name]
|
||||
}
|
||||
|
||||
function removeMissingMediaByName(name: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => m.name !== name
|
||||
)
|
||||
clearInteractionStateForName(name)
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set(
|
||||
missingMediaCandidates.value
|
||||
.filter(
|
||||
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
|
||||
)
|
||||
.map((m) => m.name)
|
||||
)
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
|
||||
)
|
||||
for (const name of removedNames) {
|
||||
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
function clearMissingMedia() {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingMediaCandidates.value = null
|
||||
expandState.value = {}
|
||||
uploadState.value = {}
|
||||
pendingSelection.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
missingMediaCandidates,
|
||||
hasMissingMedia,
|
||||
missingMediaCount,
|
||||
missingMediaNodeIds,
|
||||
missingMediaAncestorExecutionIds,
|
||||
activeMissingMediaGraphIds,
|
||||
|
||||
setMissingMedia,
|
||||
removeMissingMediaByName,
|
||||
removeMissingMediaByWidget,
|
||||
clearMissingMedia,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingMediaOnNode,
|
||||
isContainerWithMissingMedia,
|
||||
|
||||
expandState,
|
||||
uploadState,
|
||||
pendingSelection
|
||||
}
|
||||
})
|
||||
38
src/platform/missingMedia/types.ts
Normal file
38
src/platform/missingMedia/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
export type MediaType = 'image' | 'video' | 'audio'
|
||||
|
||||
/**
|
||||
* A single (node, widget, media file) binding detected by the missing media pipeline.
|
||||
* The same file name may appear multiple times across different nodes.
|
||||
*/
|
||||
export interface MissingMediaCandidate {
|
||||
nodeId: NodeId
|
||||
nodeType: string
|
||||
widgetName: string
|
||||
mediaType: MediaType
|
||||
/** Display name (plain filename for OSS, asset hash for cloud). */
|
||||
name: string
|
||||
/**
|
||||
* - `true` — confirmed missing
|
||||
* - `false` — confirmed present
|
||||
* - `undefined` — pending async verification (cloud only)
|
||||
*/
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
/** View model grouping multiple candidate references under a single file name. */
|
||||
export interface MissingMediaViewModel {
|
||||
name: string
|
||||
mediaType: MediaType
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** A group of missing media items sharing the same media type. */
|
||||
export interface MissingMediaGroup {
|
||||
mediaType: MediaType
|
||||
items: MissingMediaViewModel[]
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
@@ -50,14 +51,6 @@ export function isModelFileName(name: string): boolean {
|
||||
return Array.from(MODEL_FILE_EXTENSIONS).some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
function resolveComboOptions(widget: IComboWidget): string[] {
|
||||
const values = widget.options.values
|
||||
if (!values) return []
|
||||
if (typeof values === 'function') return values(widget)
|
||||
if (Array.isArray(values)) return values
|
||||
return Object.keys(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
|
||||
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
|
||||
@@ -139,7 +132,7 @@ function scanComboWidget(
|
||||
if (!isModelFileName(value)) return null
|
||||
|
||||
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
|
||||
const options = resolveComboOptions(widget)
|
||||
const options = resolveComboValues(widget)
|
||||
const inOptions = options.includes(value)
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,11 +19,27 @@ import {
|
||||
} from './useSlotElementTracking'
|
||||
|
||||
const mockGraph = vi.hoisted(() => ({ _nodes: [] as unknown[] }))
|
||||
const mockCanvasState = vi.hoisted(() => ({
|
||||
canvas: {} as object | null
|
||||
}))
|
||||
const mockClientPosToCanvasPos = vi.hoisted(() =>
|
||||
vi.fn(([x, y]: [number, number]) => [x * 0.5, y * 0.5] as [number, number])
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: mockGraph, setDirty: vi.fn() } }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasState
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: mockClientPosToCanvasPos
|
||||
})
|
||||
}))
|
||||
|
||||
const NODE_ID = 'test-node'
|
||||
const SLOT_INDEX = 0
|
||||
|
||||
@@ -45,9 +61,10 @@ function createWrapperComponent(type: 'input' | 'output') {
|
||||
})
|
||||
}
|
||||
|
||||
function createSlotElement(): HTMLElement {
|
||||
function createSlotElement(collapsed = false): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
container.dataset.nodeId = NODE_ID
|
||||
if (collapsed) container.dataset.collapsed = ''
|
||||
container.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -113,6 +130,8 @@ describe('useSlotElementTracking', () => {
|
||||
actor: 'test'
|
||||
})
|
||||
mockGraph._nodes = [{ id: 1 }]
|
||||
mockCanvasState.canvas = {}
|
||||
mockClientPosToCanvasPos.mockClear()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -251,4 +270,57 @@ describe('useSlotElementTracking', () => {
|
||||
|
||||
expect(batchUpdateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('collapsed node slot sync', () => {
|
||||
function registerCollapsedSlot() {
|
||||
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
|
||||
const slotEl = createSlotElement(true)
|
||||
|
||||
const registryStore = useNodeSlotRegistryStore()
|
||||
const node = registryStore.ensureNode(NODE_ID)
|
||||
node.slots.set(slotKey, {
|
||||
el: slotEl,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input',
|
||||
cachedOffset: { x: 50, y: 60 }
|
||||
})
|
||||
|
||||
return { slotKey, node }
|
||||
}
|
||||
|
||||
it('uses clientPosToCanvasPos for collapsed nodes', () => {
|
||||
const { slotKey } = registerCollapsedSlot()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
// Slot element center: (10 + 10/2, 30 + 10/2) = (15, 35)
|
||||
const screenCenter: [number, number] = [15, 35]
|
||||
expect(mockClientPosToCanvasPos).toHaveBeenCalledWith(screenCenter)
|
||||
|
||||
// Mock returns x*0.5, y*0.5
|
||||
const layout = layoutStore.getSlotLayout(slotKey)
|
||||
expect(layout).not.toBeNull()
|
||||
expect(layout!.position.x).toBe(screenCenter[0] * 0.5)
|
||||
expect(layout!.position.y).toBe(screenCenter[1] * 0.5)
|
||||
})
|
||||
|
||||
it('clears cachedOffset for collapsed nodes', () => {
|
||||
const { slotKey, node } = registerCollapsedSlot()
|
||||
const entry = node.slots.get(slotKey)!
|
||||
expect(entry.cachedOffset).toBeDefined()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(entry.cachedOffset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defers sync when canvas is not initialized', () => {
|
||||
mockCanvasState.canvas = null
|
||||
registerCollapsedSlot()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(mockClientPosToCanvasPos).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -134,11 +136,26 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
|
||||
.value?.el.closest('[data-node-id]')
|
||||
const nodeEl = closestNode instanceof HTMLElement ? closestNode : null
|
||||
const nodeRect = nodeEl?.getBoundingClientRect()
|
||||
|
||||
// Collapsed nodes preserve expanded size in layoutStore, so DOM-relative
|
||||
// scale derivation breaks. Fall back to clientPosToCanvasPos instead.
|
||||
const isCollapsed = nodeEl?.dataset.collapsed != null
|
||||
const effectiveScale =
|
||||
nodeRect && nodeLayout.size.width > 0
|
||||
!isCollapsed && nodeRect && nodeLayout.size.width > 0
|
||||
? nodeRect.width / nodeLayout.size.width
|
||||
: 0
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const conv =
|
||||
isCollapsed && canvasStore.canvas
|
||||
? useSharedCanvasPositionConversion()
|
||||
: null
|
||||
|
||||
if (isCollapsed && !conv) {
|
||||
scheduleSlotLayoutSync(nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
const batch: Array<{ key: string; layout: SlotLayout }> = []
|
||||
|
||||
for (const [slotKey, entry] of node.slots) {
|
||||
@@ -155,22 +172,30 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
|
||||
rect.top + rect.height / 2
|
||||
]
|
||||
|
||||
if (!nodeRect || effectiveScale <= 0) continue
|
||||
let centerCanvas: { x: number; y: number }
|
||||
|
||||
// DOM-relative measurement: compute offset from the node element's
|
||||
// top-left corner in canvas units. The node element is rendered at
|
||||
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
|
||||
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
|
||||
entry.cachedOffset = {
|
||||
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
|
||||
y:
|
||||
(screenCenter[1] - nodeRect.top) / effectiveScale -
|
||||
LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
if (conv) {
|
||||
const [cx, cy] = conv.clientPosToCanvasPos(screenCenter)
|
||||
centerCanvas = { x: cx, y: cy }
|
||||
entry.cachedOffset = undefined
|
||||
} else {
|
||||
if (!nodeRect || effectiveScale <= 0) continue
|
||||
|
||||
const centerCanvas = {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
// DOM-relative measurement: compute offset from the node element's
|
||||
// top-left corner in canvas units. The node element is rendered at
|
||||
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
|
||||
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
|
||||
entry.cachedOffset = {
|
||||
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
|
||||
y:
|
||||
(screenCenter[1] - nodeRect.top) / effectiveScale -
|
||||
LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
centerCanvas = {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
}
|
||||
}
|
||||
|
||||
const nextLayout = createSlotLayout({
|
||||
|
||||
@@ -15,8 +15,8 @@ import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
|
||||
@@ -11,8 +11,10 @@ import { isImageUploadInput } from '@/types/nodeDefAugmentation'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
import {
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
ACCEPTED_VIDEO_TYPES
|
||||
} from '@/utils/mediaUploadUtil'
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||
|
||||
@@ -92,6 +92,11 @@ import {
|
||||
verifyAssetSupportedCandidates
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -1137,6 +1142,7 @@ export class ComfyApp {
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
useMissingModelStore().clearMissingModels()
|
||||
useMissingMediaStore().clearMissingMedia()
|
||||
|
||||
if (clean !== false) {
|
||||
// Reset canvas context before configuring a new graph so subgraph UI
|
||||
@@ -1416,6 +1422,8 @@ export class ComfyApp {
|
||||
showMissingModels
|
||||
)
|
||||
|
||||
await this.runMissingMediaPipeline()
|
||||
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
}
|
||||
@@ -1565,6 +1573,44 @@ export class ComfyApp {
|
||||
return { missingModels }
|
||||
}
|
||||
|
||||
private async runMissingMediaPipeline(): Promise<void> {
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
|
||||
if (!candidates.length) return
|
||||
|
||||
if (isCloud) {
|
||||
const controller = missingMediaStore.createVerificationAbortController()
|
||||
void verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
'[Missing Media Pipeline] Asset verification failed:',
|
||||
err
|
||||
)
|
||||
useToastStore().add({
|
||||
severity: 'warn',
|
||||
summary: st(
|
||||
'toastMessages.missingMediaVerificationFailed',
|
||||
'Failed to verify missing media. Some inputs may not be shown in the Errors tab.'
|
||||
),
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async graphToPrompt(graph = this.rootGraph) {
|
||||
return graphToPrompt(graph, {
|
||||
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
|
||||
@@ -4,7 +4,9 @@ import { computed, ref } from 'vue'
|
||||
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -34,6 +36,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
@@ -157,6 +160,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
options
|
||||
)
|
||||
missingModelStore.removeMissingModelByWidget(executionId, widgetName)
|
||||
missingMediaStore.removeMissingMediaByWidget(executionId, widgetName)
|
||||
}
|
||||
|
||||
/** Set missing models and open the error overlay if the Errors tab is enabled. */
|
||||
@@ -170,6 +174,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Set missing media and open the error overlay if the Errors tab is enabled. */
|
||||
function surfaceMissingMedia(media: MissingMediaCandidate[]) {
|
||||
missingMediaStore.setMissingMedia(media)
|
||||
if (
|
||||
media.length &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
showErrorOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
const lastExecutionErrorNodeLocatorId = computed(() => {
|
||||
const err = lastExecutionError.value
|
||||
if (!err) return null
|
||||
@@ -197,7 +212,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
hasPromptError.value ||
|
||||
hasNodeError.value ||
|
||||
missingNodesStore.hasMissingNodes ||
|
||||
missingModelStore.hasMissingModels
|
||||
missingModelStore.hasMissingModels ||
|
||||
missingMediaStore.hasMissingMedia
|
||||
)
|
||||
|
||||
const allErrorExecutionIds = computed<string[]>(() => {
|
||||
@@ -233,7 +249,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
nodeErrorCount.value +
|
||||
executionErrorCount.value +
|
||||
missingNodesStore.missingNodeCount +
|
||||
missingModelStore.missingModelCount
|
||||
missingModelStore.missingModelCount +
|
||||
missingMediaStore.missingMediaCount
|
||||
)
|
||||
|
||||
/** Graph node IDs (as strings) that have errors in the current graph scope. */
|
||||
@@ -326,7 +343,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return errorAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
|
||||
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
|
||||
|
||||
return {
|
||||
// Raw state
|
||||
@@ -360,6 +377,9 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
// Missing model coordination (delegates to missingModelStore)
|
||||
surfaceMissingModels,
|
||||
|
||||
// Missing media coordination (delegates to missingMediaStore)
|
||||
surfaceMissingMedia,
|
||||
|
||||
// Lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const createMockNode = (overrides: Record<string, unknown> = {}): LGraphNode =>
|
||||
fromAny<LGraphNode, Record<string, unknown>>({
|
||||
fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
...overrides
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -20,7 +20,7 @@ function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
|
||||
nodes: []
|
||||
} satisfies MockSubgraph
|
||||
|
||||
return fromAny<Subgraph, unknown>(mockSubgraph)
|
||||
return fromPartial<Subgraph>(mockSubgraph)
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
|
||||
@@ -108,6 +108,14 @@ export function isAudioNode(node: LGraphNode | undefined): boolean {
|
||||
return !!node && node.previewMediaType === 'audio'
|
||||
}
|
||||
|
||||
export function resolveComboValues(widget: IComboWidget): string[] {
|
||||
const values = widget.options?.values
|
||||
if (!values) return []
|
||||
if (typeof values === 'function') return values(widget)
|
||||
if (Array.isArray(values)) return values
|
||||
return Object.keys(values)
|
||||
}
|
||||
|
||||
export function addToComboValues(widget: IComboWidget, value: string) {
|
||||
if (!widget.options) widget.options = { values: [] }
|
||||
if (!widget.options.values) widget.options.values = []
|
||||
|
||||
5
src/utils/mediaUploadUtil.ts
Normal file
5
src/utils/mediaUploadUtil.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Accepted MIME types for the image upload file picker. */
|
||||
export const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
|
||||
/** Accepted MIME types for the video upload file picker. */
|
||||
export const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -50,7 +50,7 @@ describe('getWidgetDefaultValue', () => {
|
||||
})
|
||||
|
||||
function makeWidget(overrides: Record<string, unknown> = {}): IBaseWidget {
|
||||
return fromAny<IBaseWidget, unknown>({
|
||||
return fromPartial<IBaseWidget>({
|
||||
name: 'myWidget',
|
||||
type: 'number',
|
||||
value: 0,
|
||||
|
||||
Reference in New Issue
Block a user