Compare commits

..

2 Commits

Author SHA1 Message Date
dante01yoon
932f5af985 fix(test): replace mask wait with non-blocking dialog hidden wait
The .p-dialog-mask wait timed out when clicking Save triggers a
second dialog (save-as filename). Use root.waitFor(hidden) with
catch instead — non-fatal if another dialog replaces it.
2026-04-02 20:26:18 +09:00
dante01yoon
52995565a3 refactor(test): extract dialog page objects from inline getByRole usage
Extract ConfirmDialog from ComfyPage.ts into its own component file and
add MediaLightbox and TemplatesDialog page objects. Refactor tests to use
these page objects instead of inline getByRole('dialog') calls.
2026-04-02 17:23:14 +09:00
48 changed files with 213 additions and 29210 deletions

View File

@@ -1,68 +0,0 @@
{
"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
}

View File

@@ -1,42 +0,0 @@
{
"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
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

View File

@@ -14,9 +14,12 @@ import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
import { ConfirmDialog } from '@e2e/fixtures/components/ConfirmDialog'
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
@@ -116,48 +119,6 @@ class ComfyMenu {
}
}
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
class ConfirmDialog {
public readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
export class ComfyPage {
public readonly url: string
// All canvas position operations are based on default view of canvas.
@@ -181,6 +142,8 @@ export class ComfyPage {
public readonly templates: ComfyTemplates
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly templatesDialog: TemplatesDialog
public readonly mediaLightbox: MediaLightbox
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
@@ -228,6 +191,8 @@ export class ComfyPage {
this.templates = new ComfyTemplates(page)
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.templatesDialog = new TemplatesDialog(page)
this.mediaLightbox = new MediaLightbox(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)

View File

@@ -0,0 +1,44 @@
import type { Locator, Page } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
type KeysOfType<T, Match> = {
[K in keyof T]: T[K] extends Match ? K : never
}[keyof T]
export class ConfirmDialog {
public readonly root: Locator
public readonly delete: Locator
public readonly overwrite: Locator
public readonly reject: Locator
public readonly confirm: Locator
public readonly save: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.delete = this.root.getByRole('button', { name: 'Delete' })
this.overwrite = this.root.getByRole('button', { name: 'Overwrite' })
this.reject = this.root.getByRole('button', { name: 'Cancel' })
this.confirm = this.root.getByRole('button', { name: 'Confirm' })
this.save = this.root.getByRole('button', { name: 'Save' })
}
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
const loc = this[locator]
await loc.waitFor({ state: 'visible' })
await loc.click()
// Wait for this confirm dialog to close (not all dialogs — another
// dialog like save-as may open immediately after).
await this.root.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() =>
(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}

View File

@@ -0,0 +1,11 @@
import type { Locator, Page } from '@playwright/test'
export class MediaLightbox {
public readonly root: Locator
public readonly closeButton: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
this.closeButton = this.root.getByLabel('Close')
}
}

View File

@@ -0,0 +1,19 @@
import type { Locator, Page } from '@playwright/test'
export class TemplatesDialog {
public readonly root: Locator
constructor(public readonly page: Page) {
this.root = page.getByRole('dialog')
}
filterByHeading(name: string): Locator {
return this.root.filter({
has: this.page.getByRole('heading', { name, exact: true })
})
}
getCombobox(name: RegExp | string): Locator {
return this.root.getByRole('combobox', { name })
}
}

View File

@@ -44,15 +44,7 @@ export const TestIds = {
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
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'
missingModelsGroup: 'error-group-missing-model'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -69,16 +61,6 @@ 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'
},
@@ -88,9 +70,6 @@ export const TestIds = {
colorBlue: 'blue',
colorRed: 'red'
},
menu: {
moreMenuContent: 'more-menu-content'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
@@ -152,7 +131,5 @@ 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]

View File

@@ -18,15 +18,13 @@ test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
.catch(() => {})
}, longFilename)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const { root, confirm, reject } = comfyPage.confirmDialog
await expect(root).toBeVisible()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
await expect(confirmButton).toBeVisible()
await expect(confirmButton).toBeInViewport()
await expect(confirm).toBeVisible()
await expect(confirm).toBeInViewport()
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await expect(cancelButton).toBeVisible()
await expect(cancelButton).toBeInViewport()
await expect(reject).toBeVisible()
await expect(reject).toBeInViewport()
})
})

View File

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

View File

@@ -1,7 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { assetPath } from '../fixtures/utils/paths'
import { logMeasurement, recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
@@ -349,63 +348,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
})
})
test.describe('workflow loading', () => {
test('large workflow load and settle', async ({ comfyPage }) => {
await comfyPage.perf.startMeasuring()
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
// Settling period: 120 frames (~2s) for DOM creation + layout stabilization
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('large-workflow-load')
recordMeasurement(m)
logMeasurement('Large workflow load', m, [
'durationMs',
'styleRecalcs',
'layouts',
'taskDurationMs',
'totalBlockingTimeMs',
'domNodes',
'heapDeltaBytes'
])
})
test('media-heavy workflow load and settle', async ({ comfyPage }) => {
// Intercept all media preview requests with a small test image
await comfyPage.page.route(/\/api\/view\?/, (route) =>
route.fulfill({
path: assetPath('image64x64.webp'),
contentType: 'image/webp'
})
)
await comfyPage.perf.startMeasuring()
await comfyPage.workflow.loadWorkflow('profiling-workflow-media-heavy')
// Longer settling: 240 frames (~4s) for 585 nodes + 128 media previews
for (let i = 0; i < 240; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('media-heavy-workflow-load')
recordMeasurement(m)
logMeasurement('Media-heavy workflow load', m, [
'durationMs',
'styleRecalcs',
'layouts',
'taskDurationMs',
'totalBlockingTimeMs',
'frameDurationMs',
'domNodes',
'heapDeltaBytes'
])
})
})
test('workflow execution', async ({ comfyPage }) => {
// Uses lightweight PrimitiveString → PreviewAny workflow (no GPU needed)
await comfyPage.workflow.loadWorkflow('execution/partial_execution')

View File

@@ -41,30 +41,28 @@ test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
await assetCard.hover()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()
return { gallery }
const { root } = comfyPage.mediaLightbox
await expect(root).toBeVisible()
}
test('opens gallery and shows dialog with close button', async ({
comfyPage
}) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await expect(gallery.getByLabel('Close')).toBeVisible()
await runAndOpenGallery(comfyPage)
await expect(comfyPage.mediaLightbox.closeButton).toBeVisible()
})
test('closes gallery on Escape key', async ({ comfyPage }) => {
await runAndOpenGallery(comfyPage)
await comfyPage.page.keyboard.press('Escape')
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
})
test('closes gallery when clicking close button', async ({ comfyPage }) => {
const { gallery } = await runAndOpenGallery(comfyPage)
await runAndOpenGallery(comfyPage)
await gallery.getByLabel('Close').click()
await expect(comfyPage.page.getByRole('dialog')).not.toBeVisible()
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).not.toBeVisible()
})
})

View File

@@ -1,148 +0,0 @@
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()
})
})
}
)

View File

@@ -116,9 +116,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const dialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.page.getByRole('heading', { name: 'Modèles', exact: true })
})
const dialog = comfyPage.templatesDialog.filterByHeading('Modèles')
await expect(dialog).toBeVisible()
// Validate that French-localized strings from the templates index are rendered
@@ -220,8 +218,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
const sortBySelect = comfyPage.templatesDialog.getCombobox(/Sort/)
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect

View File

@@ -460,11 +460,8 @@ test.describe('Workflow Persistence', () => {
.getWorkflowTab('Unsaved Workflow')
.click({ button: 'middle' })
// Click "Save" in the dirty close dialog (scoped to dialog)
const dialog = comfyPage.page.getByRole('dialog')
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.waitFor({ state: 'visible' })
await saveButton.click()
// Click "Save" in the dirty close dialog
await comfyPage.confirmDialog.click('save')
// Fill in the filename dialog
const saveDialog = comfyPage.menu.topbar.getSaveDialog()

View File

@@ -51,10 +51,7 @@
}
"
>
<div
class="flex min-w-40 flex-col gap-2 p-2"
data-testid="more-menu-content"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" />
</div>
</Popover>

View File

@@ -303,7 +303,6 @@ function handleTitleCancel() {
v-if="isSingleSubgraphNode"
variant="secondary"
size="icon"
data-testid="subgraph-editor-toggle"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@click="
rightSidePanelStore.openPanel(

View File

@@ -15,7 +15,6 @@
<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
@@ -169,15 +168,7 @@
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@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"
@locate-model="handleLocateModel"
/>
</PropertiesAccordionItem>
</TransitionGroup>
@@ -234,7 +225,6 @@ 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,
@@ -271,8 +261,7 @@ const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model',
'missing_media'
'missing_model'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
@@ -294,7 +283,6 @@ const {
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -405,7 +393,7 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateAssetNode(nodeId: string) {
function handleLocateModel(nodeId: string) {
focusNode(nodeId)
}

View File

@@ -25,4 +25,3 @@ 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 }

View File

@@ -20,7 +20,7 @@ export function useErrorActions() {
is_external: true,
source: 'error_dialog'
})
return commandStore.execute('Comfy.ContactSupport')
void commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {

View File

@@ -4,7 +4,6 @@ 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'
@@ -30,9 +29,7 @@ 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
@@ -242,7 +239,6 @@ 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>>({})
@@ -639,27 +635,6 @@ 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>()
@@ -670,7 +645,6 @@ export function useErrorGroups(
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...toSortedGroups(groupsMap)
]
})
@@ -689,7 +663,6 @@ export function useErrorGroups(
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...executionGroups
]
})
@@ -726,7 +699,6 @@ export function useErrorGroups(
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
}
}

View File

@@ -1,4 +1,4 @@
import { computed, reactive, toValue, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
@@ -28,73 +28,66 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
)
})
watch(
() => toValue(cardSource),
async (card, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
let cancelled = false
onUnmounted(() => {
cancelled = true
})
for (const key of Object.keys(enrichedDetails)) {
delete enrichedDetails[key as unknown as number]
}
onMounted(async () => {
const card = toValue(cardSource)
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
if (runtimeErrors.length === 0) 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
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
return app.rootGraph.serialize()
await systemStatsStore.refetchSystemStats()
} 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)
console.warn('Failed to fetch system stats for error report:', e)
return
}
}
},
{ immediate: true }
)
}
if (!systemStatsStore.systemStats || 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
}
})()
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)
}
}
})
return { displayedDetailsMap }
}

View File

@@ -4,6 +4,7 @@ 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'
@@ -78,6 +79,7 @@ function isWidgetShownOnParents(
): boolean {
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
const sourceNodeId = getSourceNodeId(widget)
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
@@ -86,7 +88,7 @@ function isWidgetShownOnParents(
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
disambiguatingSourceNodeId: sourceNodeId
})
}
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {

View File

@@ -14,7 +14,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import {
getSourceNodeId,
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'
@@ -129,9 +132,7 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
disambiguatingSourceNodeId: getSourceNodeId(widget)
})
)
})

View File

@@ -98,8 +98,7 @@ describe('WidgetActions', () => {
type: 'TestNode',
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [200, 100],
isSubgraphNode: () => false
size: [200, 100]
})
}
@@ -226,8 +225,7 @@ describe('WidgetActions', () => {
const node = fromAny<LGraphNode, unknown>({
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' },
isSubgraphNode: () => false
rootGraph: { id: 'graph-test' }
})
const widget = {
name: 'text',

View File

@@ -9,7 +9,7 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
isLinkedPromotion,
getSourceNodeId,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -47,11 +47,6 @@ 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
)
@@ -81,6 +76,8 @@ function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const disambiguatingSourceNodeId = getSourceNodeId(widget)
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
@@ -88,7 +85,7 @@ function handleHideInput() {
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
@@ -117,7 +114,6 @@ 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 }">
@@ -137,7 +133,7 @@ function handleResetToDefault() {
</Button>
<Button
v-if="canToggleVisibility"
v-if="hasParents"
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"

View File

@@ -11,7 +11,6 @@ import {
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
@@ -89,13 +88,14 @@ const activeWidgets = computed<WidgetItem[]>({
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
}))
value.map(([n, w]) => {
const sid = getSourceNodeId(w)
return {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
...(sid && { disambiguatingSourceNodeId: sid })
}
})
)
refreshPromotedWidgetRendering()
}
@@ -123,9 +123,7 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
disambiguatingSourceNodeId: getSourceNodeId(w)
})
)
})
@@ -164,18 +162,6 @@ 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
@@ -201,14 +187,8 @@ 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)
}
}
@@ -243,7 +223,6 @@ onMounted(() => {
<div
v-if="filteredActive.length"
data-testid="subgraph-editor-shown-section"
class="flex flex-col border-b border-interface-stroke"
>
<div
@@ -265,8 +244,8 @@ onMounted(() => {
:key="toKey([node, widget])"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.label || widget.name"
:is-physical="isItemLinked([node, widget])"
:widget-name="widget.name"
:is-physical="node.id === -1"
:is-draggable="!searchQuery"
@toggle-visibility="demote([node, widget])"
/>
@@ -275,7 +254,6 @@ onMounted(() => {
<div
v-if="filteredCandidates.length"
data-testid="subgraph-editor-hidden-section"
class="flex flex-col border-b border-interface-stroke"
>
<div

View File

@@ -1,17 +1,9 @@
<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 {
nodeTitle,
widgetName,
isDraggable = false,
isPhysical = false,
class: className
} = defineProps<{
const props = defineProps<{
nodeTitle: string
widgetName: string
isDraggable?: boolean
@@ -22,13 +14,13 @@ defineEmits<{
(e: 'toggleVisibility'): void
}>()
const icon = computed(() =>
isPhysical
function getIcon() {
return props.isPhysical
? 'icon-[lucide--link]'
: isDraggable
: props.isDraggable
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
)
}
</script>
<template>
@@ -37,8 +29,8 @@ const icon = computed(() =>
cn(
'flex items-center gap-1 rounded-sm px-2 py-1 break-all',
'bg-node-component-surface',
isDraggable && 'ring-accent-background hover:ring-1',
className
props.isDraggable && 'ring-accent-background hover:ring-1',
props.class
)
"
>
@@ -46,18 +38,15 @@ const icon = computed(() =>
<div class="line-clamp-1 text-xs text-text-secondary">
{{ nodeTitle }}
</div>
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
{{ widgetName }}
</div>
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
</div>
<Button
variant="muted-textonly"
size="sm"
data-testid="subgraph-widget-toggle"
:disabled="isPhysical"
@click.stop="$emit('toggleVisibility')"
>
<i :class="icon" :data-testid="isPhysical ? 'icon-link' : 'icon-eye'" />
<i :class="getIcon()" />
</Button>
<div
v-if="isDraggable"

View File

@@ -3,7 +3,6 @@ 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'
@@ -33,8 +32,7 @@ function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>,
missingMediaExecIds: Set<string> = new Set()
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
@@ -66,11 +64,6 @@ 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))
@@ -85,8 +78,7 @@ function reconcileNodeErrorFlags(
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
missingModelStore: ReturnType<typeof useMissingModelStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
@@ -97,13 +89,12 @@ export function useNodeErrorFlagSync(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model/media error flags
// when the Errors tab is hidden, since legacy nodes lack the per-widget
// Legacy (LGraphNode) only: suppress missing-model 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(
@@ -111,9 +102,6 @@ export function useNodeErrorFlagSync(
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),
showErrorsTab.value
? missingMediaStore.missingMediaAncestorExecutionIds
: new Set()
)
},

View File

@@ -20,7 +20,6 @@ import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
@@ -335,84 +334,3 @@ 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)
})
})

View File

@@ -27,27 +27,6 @@ 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
@@ -60,9 +39,7 @@ function toPromotionSource(
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
disambiguatingSourceNodeId: getSourceNodeId(widget)
}
}

View File

@@ -3528,23 +3528,6 @@
"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": {

View File

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

View File

@@ -1,147 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.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>

View File

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

View File

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

View File

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

View File

@@ -1,159 +0,0 @@
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])
}))
}

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
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[]
}

View File

@@ -22,7 +22,6 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { resolveComboValues } from '@/utils/litegraphUtil'
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
@@ -51,6 +50,14 @@ 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.
@@ -132,7 +139,7 @@ function scanComboWidget(
if (!isModelFileName(value)) return null
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
const options = resolveComboValues(widget)
const options = resolveComboOptions(widget)
const inOptions = options.includes(value)
return {

View File

@@ -11,10 +11,8 @@ import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { addToComboValues } from '@/utils/litegraphUtil'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')

View File

@@ -92,11 +92,6 @@ 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'
@@ -1142,7 +1137,6 @@ export class ComfyApp {
useWorkflowService().beforeLoadNewGraph()
useMissingModelStore().clearMissingModels()
useMissingMediaStore().clearMissingMedia()
if (clean !== false) {
// Reset canvas context before configuring a new graph so subgraph UI
@@ -1422,8 +1416,6 @@ export class ComfyApp {
showMissingModels
)
await this.runMissingMediaPipeline()
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
}
@@ -1573,44 +1565,6 @@ 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')

View File

@@ -4,9 +4,7 @@ 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'
@@ -36,7 +34,6 @@ 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)
@@ -160,7 +157,6 @@ 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. */
@@ -174,17 +170,6 @@ 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
@@ -212,8 +197,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
hasPromptError.value ||
hasNodeError.value ||
missingNodesStore.hasMissingNodes ||
missingModelStore.hasMissingModels ||
missingMediaStore.hasMissingMedia
missingModelStore.hasMissingModels
)
const allErrorExecutionIds = computed<string[]>(() => {
@@ -249,8 +233,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
nodeErrorCount.value +
executionErrorCount.value +
missingNodesStore.missingNodeCount +
missingModelStore.missingModelCount +
missingMediaStore.missingMediaCount
missingModelStore.missingModelCount
)
/** Graph node IDs (as strings) that have errors in the current graph scope. */
@@ -343,7 +326,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
return {
// Raw state
@@ -377,9 +360,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Missing model coordination (delegates to missingModelStore)
surfaceMissingModels,
// Missing media coordination (delegates to missingMediaStore)
surfaceMissingMedia,
// Lookup helpers
getNodeErrors,
slotHasError,

View File

@@ -108,14 +108,6 @@ 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 = []

View File

@@ -1,5 +0,0 @@
/** 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'