Compare commits

..

5 Commits

Author SHA1 Message Date
Matt Miller
47118ef64f fix(image): handle useImage load errors instead of reporting them as unhandled (#12729)
## ELI-5

When an image on the page fails to load — a broken thumbnail, an expired
share
link, a flaky in-app browser — the app already handles it and shows a
fallback.
But under the hood, the image helper was *also* shouting "uncaught
error!" to the
browser's global error channel every time. Our monitoring hears that
shout and
logs it as a crash. With enough broken images (some in-app browsers
retry in a
loop), it became the single loudest "error" in our telemetry — for
something
that isn't actually broken. This tells the helper to handle the failure
quietly
instead of shouting.

## What

`useImage()` (from `@vueuse/core`) exposes load failures via its `error`
ref,
which every call site here already uses to render a fallback. But
vueuse's
default `onError` forwards the error to `globalThis.reportError`, so
each failed
`<img>` load also surfaces as an **unhandled** error to global error
monitoring.

This makes failed image loads — 404'd thumbnails, expired share links,
in-app
webviews that re-fetch on a loop — the highest-volume unhandled frontend
error
in our production telemetry, despite being expected and already handled
in the UI.

## Fix

Pass an explicit `onError` (a documented no-op) as the `useAsyncState`
options
argument at all four `useImage()` call sites:

- `components/common/ComfyImage.vue`
- `platform/workflow/sharing/components/ShareAssetThumbnail.vue`
- `platform/assets/components/MediaImageTop.vue`
- `platform/assets/components/AssetCard.vue`

The `error` ref is still set by `useAsyncState` before `onError` runs,
so the
fallback-UI behaviour is unchanged — the only difference is we stop
re-reporting
handled failures to the global error handler.

## Test plan

- [x] No behavioural change to the `error` ref / fallback rendering
(verified
against vueuse `useImage`/`useAsyncState` semantics: `error.value` is
assigned
  independently of `onError`).
- [ ] CI lint/format/type checks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-09 18:36:21 +00:00
Matt Miller
f110af79f7 fix(widgetStore): tolerate null/undefined custom widgets from extensions (#12728)
## ELI-5

Some custom nodes have a `getCustomWidgets()` function that's *supposed*
to hand
us a list of widgets. A few of them hand us back nothing
(null/undefined)
instead. We were trying to read that "nothing" like a list, which
crashes with
*"Cannot convert undefined or null to object"* — and because it happens
while
the app is still starting up, it can break the whole page. This PR just
says
"if there's nothing to register, skip it."

## What

`registerCustomWidgets` called `Object.entries(newWidgets)` directly.
When an
extension's `getCustomWidgets()` resolves to `null`/`undefined` (it's
typed
non-null, but extensions are untrusted and routinely violate the type),
this
throws `TypeError: Cannot convert undefined or null to object`.

The call site in `extensionService.ts` runs this inside a bare async
IIFE,
*outside* the `wrapWithErrorHandling` wrappers used for
keybindings/settings, so
the throw is unhandled and surfaces during app initialization.

## Why it matters

In production this is one of the highest-volume unhandled frontend
errors —
~2.6k events across **~1,160 distinct sessions/day**, all funneling
through this
one `Object.entries` call. Guarding the choke point silences it for
every
caller.

## Fix

- Keep `registerCustomWidgets` typed `Record<string,
ComfyWidgetConstructor>`
(the correct internal contract) and early-return on nullish input. The
runtime
guard defends against untrusted extensions that violate the type at the
  boundary, without weakening the signature for legitimate callers.
- Add a regression test asserting
`registerCustomWidgets(null!/undefined!)` does
  not throw (the `!` casts simulate the boundary violation).

## Test plan

- [x] `npx vitest run src/stores/widgetStore.test.ts` — 8 passing,
including the
  new null/undefined case.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 18:32:51 +00:00
Steven Tran
8972d27689 refactor(telemetry): route execution events to GTM only (MAR-282) (#12717)
## Summary
Client-side execution events (`execution_start` / `execution_success` /
`execution_error`) are now emitted only by the GTM provider, removing
the redundant Mixpanel and PostHog emissions that duplicated the
server-side PostHog execution pipeline.

## Changes
- **Removed** `trackWorkflowExecution`, `trackExecutionError`, and
`trackExecutionSuccess` from `MixpanelTelemetryProvider` and
`PostHogTelemetryProvider`, along with the now-unused
`lastTriggerSource` field and related type imports.
- **Kept** these methods on `GtmTelemetryProvider`. The
`TelemetryProvider` interface declares them optional and
`TelemetryRegistry` dispatches via optional chaining, so callers are
unchanged and Mixpanel/PostHog simply receive nothing for these events.
- **Added** GTM unit tests for `execution_start` and `execution_success`
(alongside the existing `execution_error` test) to pin the remaining
client-side path.

## Review Focus
- Execution telemetry on the client now flows exclusively to GTM;
PostHog execution data is expected to come solely from the server side,
so there should be no double-counting.
- The server-side PostHog execution pipeline is out of scope for this
frontend change — this PR only stops the client from emitting duplicate
execution events.

Reference: MAR-282
Prior context: Comfy-Org PR #3423.

Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
2026-06-09 17:26:34 +00:00
jaeone94
72d1261983 [bugfix] Use Desktop2 bridge for missing model downloads (#12710)
## Summary

Fixes the Desktop2 missing-model download path so the frontend calls the
Desktop2 download bridge directly when it is available, instead of
relying on the browser `<a download>` fallback that Desktop2 currently
has to intercept indirectly.

This addresses Linear FE-956, where missing-model downloads on Windows
could open the OS Save As dialog. The issue was reproducible when the
frontend language was not English: switching the UI language back to
English made the download succeed again.

## Root Cause

Desktop2 currently has compatibility logic that watches/intercepts the
frontend missing-model download flow from outside the FE code. That
interception depends on FE-rendered DOM details, including localized
accessible labels such as the missing-model download button
`aria-label`.

In English, Desktop2 could find the expected download controls and cache
the missing-model metadata before the FE-created `<a>` download was
clicked. In non-English locales, the localized label no longer matched
Desktop2's selector, so the Desktop2 interception path missed the
download. The FE then continued down the browser download path, which
Electron surfaced as a native Save As dialog on Windows.

## Changes

- Adds a narrow Desktop2 runtime bridge check in
`missingModelDownload.ts`:
  - if `window.__comfyDesktop2.downloadModel` exists
  - and `window.__comfyDesktop2Remote` is not set
- then FE calls `window.__comfyDesktop2.downloadModel(model.url,
model.name, model.directory)` directly and returns early.
- Keeps remote Desktop2 sessions on the existing browser fallback path
by preserving the `__comfyDesktop2Remote` guard.
- Leaves the existing OSS browser fallback and legacy desktop
`isDesktop` download-store path intact.
- Logs Desktop2 bridge failures so rejected promises or synchronous
bridge throws do not become unhandled errors.
- Adds regression coverage for:
- Desktop2 bridge path taking priority over browser and legacy desktop
fallbacks.
- rejected Desktop2 bridge calls being logged without falling back to
browser download.
- synchronously thrown Desktop2 bridge failures being logged without
crashing or falling back to browser download.
  - remote Desktop2 sessions continuing to use browser fallback.

## User Impact

Desktop2 users should no longer depend on localized FE DOM text for
missing-model downloads. In particular, non-English UI locales should
route missing-model downloads through Desktop2's managed downloader
instead of opening the OS Save As dialog.

## Validation

- Manually verified the issue is fixed in Desktop2 using a locally built
FE dist served through ComfyUI with `--front-end-root`.
- Verified Korean locale no longer triggers the Save As dialog and the
missing-model download succeeds through Desktop2.
- Verified the new regression test fails when the production bridge fix
is reverted.
- Covered the FE-side contract with unit tests because a true end-to-end
assertion of the Windows native Save As dialog is not currently
practical in the FE browser-test infrastructure. The FE tests can verify
that clicking missing-model download routes into
`window.__comfyDesktop2.downloadModel`; they cannot directly prove
Electron/Windows native dialog behavior. That full native-dialog
regression belongs in Desktop2/Electron integration coverage.
- Ran:
- `pnpm exec oxfmt --check
src/platform/missingModel/missingModelDownload.ts
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm lint:unstaged`
- `pnpm exec vitest run
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm typecheck`
  - `pnpm build`
- Pre-commit hook passed: `oxfmt`, `oxlint`, `eslint`, `typecheck`.
- Pre-push hook passed: `knip --cache` completed with existing tag hints
only.
- Ran a 3-round local Claude review loop; final verdict was approve with
no Blocker/Major findings.

## Follow-up Work

- Define and document the FE/Desktop2 bridge contract explicitly,
including the expected semantics of `downloadModel` resolving `false`
versus rejecting.
- Add a shared or canonical TypeScript declaration for
`window.__comfyDesktop2` and `window.__comfyDesktop2Remote` if more FE
code starts depending on these globals.
- Remove Desktop2's DOM/aria/class-based missing-model download
interception after a sufficient FE compatibility window, so Desktop2 no
longer depends on FE DOM structure or localized labels.
- Add Desktop2 integration/e2e coverage for missing-model downloads in
non-English locales, ideally including Windows where the Save As dialog
was observed. This is the right layer for a true native Save As
regression test.
- Optionally add a lighter FE browser E2E that injects a fake
`window.__comfyDesktop2.downloadModel` and verifies the missing-model UI
calls that bridge. This would validate the FE contract, but it would
still not replace Desktop2/Electron coverage for native dialog behavior.
- Decide on user-facing failure UX for Desktop2 bridge download failures
once Desktop2 defines whether failures, cancellations, and
already-queued downloads are represented by rejection or by `false`.

## Notes

This intentionally does not fall back to browser download when the
Desktop2 bridge resolves `false`. Falling back there could reintroduce
the exact Save As dialog behavior this PR fixes, and the meaning of
`false` should be clarified in the Desktop2 bridge contract before FE
invents user-facing behavior for it.

A true E2E test for this bug would need to exercise Desktop2/Electron on
Windows and assert that the native Save As dialog is not opened. The
current FE browser-test infrastructure cannot observe that native
Desktop2 behavior directly, so this PR uses focused unit regression
coverage for the FE routing contract plus manual Desktop2 verification.
2026-06-09 16:42:19 +00:00
jaeone94
1b90696459 Simplify missing media error presentation (#12705)
## Summary

This is the second PR in the planned Workflow Overview error-tab
modernization stack. It follows the merged execution-error slice in
#12683 and focuses only on Missing Media / Missing Inputs.

The intent is to simplify missing-media errors from
media-type/file-resolution cards into compact node-level guidance.
Missing media is now presented as a flat list of affected node inputs,
matching the new catalog-driven item-row direction introduced by the
first PR while intentionally avoiding the larger missing-node,
swap-node, and missing-model redesigns.

This PR also removes the upload/library selection workflow from the
error tab. Missing media rows now help the user locate the affected
node/input directly, rather than attempting to resolve the missing file
inside the error card.

## Changes

- **What**: Simplifies the Missing Inputs card into a flat list of
affected node/widget rows.
- **What**: Displays each missing media row with a catalog-resolved item
label in the form `{nodeName} - {inputName}`.
- **What**: Makes both the row label and right-aligned locate icon
navigate to the affected node on canvas.
- **What**: Adds item-specific accessible labels for locate icon buttons
and an explicit focus-visible state for clickable row labels.
- **What**: Uses the existing `list-scale` transition pattern so missing
media rows animate consistently when they enter or leave.
- **What**: Removes the old image/video/audio sub-sections from the
missing-media card.
- **What**: Removes missing-media
upload/dropzone/library-selection/status/confirm/cancel UI, because
resolution is no longer handled inside this card.
- **What**: Removes stale E2E selectors and tests tied to the deleted
upload/library controls.
- **What**: Removes stale English missing-media locale keys for the
deleted controls; translated locale files are intentionally left to the
localization sync process.
- **What**: Removes stale missing-media store interaction state
(`expandState`, `uploadState`, `pendingSelection`) and the now-unused
name-based removal API that only supported the deleted upload/library
flow.
- **What**: Keeps the remaining widget/node/prefix candidate-removal
APIs used by the missing-media lifecycle.
- **What**: Carries `nodeType` through missing-media referencing nodes
so fallback labels remain correct when the same missing filename is
referenced by multiple node types.
- **Breaking**: None expected. This changes the missing-media error-tab
UX, but does not change the workflow schema or backend APIs.
- **Dependencies**: None.

## Planned stack

This PR is part of the same staged redesign plan described in #12683.
The goal is to keep each error category reviewable and stable before
moving to the next slice.

1. Execution errors, merged in #12683: validation, runtime, and prompt
error grouping/presentation.
2. Missing media, this PR: simplify image/video/audio missing-media
cards into node-level catalog item rows with locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

This PR intentionally does not change missing node packs, swap nodes,
missing models, runtime errors, validation errors, or prompt errors.

## Review Focus

- Missing media rows should now be node-level rows, not file-level rows
and not image/video/audio sections.
- If the same missing media filename is referenced by multiple nodes,
each affected node/input should render as its own row.
- Clicking either the row label or locate icon should focus the
corresponding affected node.
- Removed upload/library controls are intentional and should not be
restored in this PR.
- The missing-media store cleanup should not remove any state or API
still used by the remaining missing-media lifecycle.
- Only `rightSidePanel.missingMedia.missingMediaTitle` should remain in
the English locale for the old missing-media UI namespace; new row copy
lives in the error catalog.
- This PR should stay scoped to Missing Media / Missing Inputs and
should not include the larger missing model/node/swap redesign work.

## Screenshots (if applicable)

This PR
<img width="483" height="339" alt="스크린샷 2026-06-08 오후 8 26 43"
src="https://github.com/user-attachments/assets/e029411e-b1db-41cd-aa6e-2bd5d86762f6"
/>

Before (Main)
<img width="484" height="802" alt="스크린샷 2026-06-08 오후 8 32 19"
src="https://github.com/user-attachments/assets/c183c18d-babe-4f4e-b4e0-706ce41298de"
/>

## Validation

- `pnpm format`
- `pnpm test:unit src/platform/missingMedia/missingMediaStore.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts`
- `pnpm typecheck`
- `pnpm knip --cache`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts
--project=chromium`
- Pre-push hook: `pnpm knip --cache`

## Review notes

Claude review was run after the main implementation and again after
cleanup. No blocker or major issues remained. The only final minor
finding was stale inline i18n fixture data in `TabErrors.test.ts`, which
was removed in the final commit.
2026-06-09 06:52:35 +00:00
33 changed files with 609 additions and 1137 deletions

View File

@@ -69,11 +69,6 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
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',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

View File

@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
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)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
test('Shows missing item label and locate action', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
).toBeVisible()
})
})
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
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 optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
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 loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
await getMediaRow(comfyPage)
.getByRole('button', { name: 'Load Image - image', exact: true })
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
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

@@ -31,9 +31,9 @@
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import { useImageQuiet } from '@/composables/useImageQuiet'
import { cn } from '@comfyorg/tailwind-utils'
const {
@@ -51,5 +51,5 @@ const {
alt?: string
}>()
const { error } = useImage(computed(() => ({ src, alt })))
const { error } = useImageQuiet(computed(() => ({ src, alt })))
</script>

View File

@@ -93,16 +93,7 @@ describe('TabErrors.vue', () => {
refreshing: 'Refreshing missing models.'
},
missingMedia: {
missingMediaTitle: 'Missing Inputs',
image: 'Images',
uploadFile: 'Upload {type}',
useFromLibrary: 'Use from Library',
confirmSelection: 'Confirm selection',
locateNode: 'Locate node',
expandNodes: 'Show referencing nodes',
collapseNodes: 'Hide referencing nodes',
cancelSelection: 'Cancel selection',
or: 'OR'
missingMediaTitle: 'Missing Inputs'
}
}
}
@@ -468,6 +459,50 @@ describe('TabErrors.vue', () => {
).toBeInTheDocument()
})
it('renders one missing media item per referencing node and locates the selected node', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'3': 'First Loader',
'4': 'Second Loader'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
] satisfies MissingMediaCandidate[]
}
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Second Loader - image' })
)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',

View File

@@ -256,7 +256,6 @@
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>

View File

@@ -131,6 +131,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useErrorGroups } from './useErrorGroups'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
function makeMissingNodeType(
type: string,
@@ -178,6 +179,24 @@ function makeModel(
}
}
function makeMedia(
name: string,
opts: {
nodeId: string | number
nodeType?: string
widgetName?: string
}
): MissingMediaCandidate {
return {
name,
nodeId: opts.nodeId,
nodeType: opts.nodeType ?? 'LoadImage',
widgetName: opts.widgetName ?? 'image',
mediaType: 'image',
isMissing: true
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
@@ -1060,6 +1079,27 @@ describe('useErrorGroups', () => {
groups.missingModelGroups.value
)
})
it('counts missing media by affected node rows, not grouped filenames', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingMedia([
makeMedia('shared.png', { nodeId: '1', nodeType: 'LoadImage' }),
makeMedia('shared.png', { nodeId: '2', nodeType: 'PreviewImage' })
])
await nextTick()
expect(store.totalErrorCount).toBe(2)
expect(groups.missingMediaGroups.value).toHaveLength(1)
expect(groups.missingMediaGroups.value[0].items).toHaveLength(1)
expect(
groups.missingMediaGroups.value[0].items[0].referencingNodes
).toHaveLength(2)
const missingMediaGroup = groups.allErrorGroups.value.find(
(group) => group.type === 'missing_media'
)
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
})
})
describe('tabErrorGroups', () => {

View File

@@ -34,6 +34,7 @@ import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import {
resolveMissingErrorMessage,
resolveRunErrorMessage
@@ -690,10 +691,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroups(): ErrorGroup[] {
if (!missingMediaGroups.value.length) return []
const totalItems = missingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
const totalRows = countMissingMediaReferences(missingMediaGroups.value)
return [
{
type: 'missing_media' as const,
@@ -702,7 +700,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}
@@ -806,9 +804,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
const totalItems = filteredMissingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
const totalRows = countMissingMediaReferences(
filteredMissingMediaGroups.value
)
return [
{
@@ -818,7 +815,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}

View File

@@ -0,0 +1,27 @@
import { useImage } from '@vueuse/core'
/**
* `useImage()` that handles load failures quietly.
*
* `useImage()` already surfaces failures via its returned `error` ref (callers
* render a fallback). By default vueuse ALSO forwards the error to
* `globalThis.reportError`, which our error monitoring (Datadog RUM) captures as
* an unhandled error for every broken image — 404'd thumbnails, expired share
* links, in-app browsers that re-fetch in a loop. Broken images are expected,
* not bugs, so handle the failure here instead of letting it surface globally.
* The returned `error` ref behaviour is unchanged.
*
* `asyncStateOptions` is forwarded to `useImage`, so callers can still tune the
* other `useAsyncState` fields; only `onError` is fixed to the quiet default.
*/
export function useImageQuiet(
options: Parameters<typeof useImage>[0],
asyncStateOptions?: Parameters<typeof useImage>[1]
) {
return useImage(options, {
...asyncStateOptions,
onError: () => {
// Surfaced via the returned `error` ref; see the doc comment above.
}
})
}

View File

@@ -3671,21 +3671,7 @@
"refreshFailed": "Failed to refresh missing models. Please try again."
},
"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"
"missingMediaTitle": "Missing Inputs"
}
},
"errorOverlay": {
@@ -3736,6 +3722,7 @@
},
"missing_media": {
"displayMessage": "A required media input has no file selected.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitleOne": "Media input missing",
"toastTitleMany": "Missing media inputs",
"toastMessageWithNode": "{nodeName} is missing a required media file.",

View File

@@ -128,10 +128,10 @@
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useImageQuiet } from '@/composables/useImageQuiet'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
@@ -190,7 +190,7 @@ const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay')
)
const { isLoading, error } = useImage({
const { isLoading, error } = useImageQuiet({
src: asset.preview_url ?? '',
alt: displayName.value
})

View File

@@ -20,8 +20,9 @@
</template>
<script setup lang="ts">
import { useImage, whenever } from '@vueuse/core'
import { whenever } from '@vueuse/core'
import { useImageQuiet } from '@/composables/useImageQuiet'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
@@ -34,7 +35,7 @@ const emit = defineEmits<{
view: []
}>()
const { state, error, isReady } = useImage({
const { state, error, isReady } = useImageQuiet({
src: asset.src ?? '',
alt: getAssetDisplayName(asset)
})

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel,
resolveRunErrorMessage
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
@@ -1579,6 +1580,32 @@ describe('errorMessageResolver', () => {
})
})
it.for([
{
source: { nodeType: 'LoadImage', widgetName: 'image' },
displayItemLabel: 'Load Image - image'
},
{
source: {
nodeDisplayName: 'Custom Loader',
nodeType: 'LoadImage',
widgetName: 'image'
},
displayItemLabel: 'Custom Loader - image'
},
{
source: { nodeType: '', widgetName: '' },
displayItemLabel: 'This node - unknown input'
}
] as const)(
'resolves missing media item labels from $source',
({ source, displayItemLabel }) => {
expect(resolveMissingMediaItemLabel(source)).toEqual({
displayItemLabel
})
}
)
it.for([
[
'image',
@@ -1649,6 +1676,44 @@ describe('errorMessageResolver', () => {
}
)
it('summarizes a shared missing media file by affected node references', () => {
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups: [
{
mediaType: 'image',
items: [
{
name: 'shared.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
referencingNodes: [
{ nodeId: '1', widgetName: 'image' },
{ nodeId: '2', widgetName: 'image' }
]
}
]
}
],
count: 2,
isCloud: false
})
).toMatchObject({
displayTitle: 'Missing Inputs (2)',
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'
})
})
it('summarizes multiple missing model and media items', () => {
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')

View File

@@ -5,13 +5,15 @@ import type {
} from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel
} from './missingErrorResolver'
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>

View File

@@ -2,7 +2,8 @@ import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { translateCatalogMessage } from './catalogI18n'
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { st } from '@/i18n'
function formatCountTitle(title: string, count: number): string {
@@ -255,6 +256,12 @@ type MissingMediaSource = Extract<
{ kind: 'missing_media' }
>
interface MissingMediaItemLabelSource {
nodeDisplayName?: string
nodeType?: string
widgetName?: string
}
function getMissingMediaItems(source: MissingMediaSource) {
return source.groups.flatMap((group) => group.items)
}
@@ -272,9 +279,29 @@ function resolveMissingMediaDisplayMessage(): string {
)
}
export function resolveMissingMediaItemLabel(
source: MissingMediaItemLabelSource
): { displayItemLabel: string } {
const nodeName = normalizeNodeName(
source.nodeDisplayName ||
formatNodeTypeName(source.nodeType ?? '') ||
undefined
)
const inputName =
source.widgetName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
return {
displayItemLabel: translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.itemLabel',
'{nodeName} - {inputName}',
{ nodeName, inputName }
)
}
}
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
if (items.length !== 1) {
if (countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleMany',
'Missing media inputs'
@@ -290,7 +317,7 @@ function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
const [firstItem] = items
if (!firstItem || items.length !== 1) {
if (!firstItem || countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageMany',
'Please select the missing media inputs before running this workflow.'

View File

@@ -1,50 +1,61 @@
<template>
<div class="px-4 pb-2">
<div
v-for="group in missingMediaGroups"
:key="group.mediaType"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
<TransitionGroup
tag="ul"
name="list-scale"
class="relative m-0 list-none space-y-1 p-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>
<li
v-for="item in missingMediaItems"
:key="item.key"
data-testid="missing-media-row"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="emit('locateNode', item.nodeId)"
>
{{ item.displayItemLabel }}
</button>
</span>
<Button
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.displayItemLabel
})
"
@click.stop="emit('locateNode', item.nodeId)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
import Button from '@/components/ui/button/Button.vue'
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { app } from '@/scripts/app'
import { st } from '@/i18n'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const { missingMediaGroups } = defineProps<{
missingMediaGroups: MissingMediaGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -53,9 +64,60 @@ const emit = defineEmits<{
const { t } = useI18n()
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
image: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
audio: 'icon-[lucide--music]'
interface MissingMediaItemEntry {
key: string
nodeId: string
displayItemLabel: string
}
const missingMediaItems = computed(() => {
return getMissingMediaReferences(missingMediaGroups)
.map(({ mediaItem, nodeRef }) => {
const nodeId = String(nodeRef.nodeId)
return {
key: `${nodeId}:${nodeRef.widgetName}:${mediaItem.name}`,
nodeId,
displayItemLabel: getDisplayItemLabel(
nodeId,
nodeRef.nodeType ?? mediaItem.representative.nodeType,
nodeRef.widgetName
)
}
})
.sort(compareMissingMediaItems)
})
function getDisplayItemLabel(
nodeId: string,
nodeType: string,
widgetName: string
) {
const nodeDisplayName = getNodeDisplayLabel(nodeId, '')
return resolveMissingMediaItemLabel({
nodeDisplayName,
nodeType,
widgetName
}).displayItemLabel
}
function compareMissingMediaItems(
a: MissingMediaItemEntry,
b: MissingMediaItemEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.displayItemLabel.localeCompare(b.displayItemLabel)
)
}
function getNodeDisplayLabel(nodeId: string, fallback: string): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, nodeId)
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
</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 '@comfyorg/tailwind-utils'
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

@@ -0,0 +1,26 @@
import { sumBy } from 'es-toolkit'
import type { MissingMediaGroup, MissingMediaViewModel } from './types'
export interface MissingMediaReference {
mediaItem: MissingMediaViewModel
nodeRef: MissingMediaViewModel['referencingNodes'][number]
}
export function getMissingMediaReferences(
groups: MissingMediaGroup[]
): MissingMediaReference[] {
return groups.flatMap((group) =>
group.items.flatMap((mediaItem) =>
mediaItem.referencingNodes.map((nodeRef) => ({ mediaItem, nodeRef }))
)
)
}
export function countMissingMediaReferences(
groups: MissingMediaGroup[]
): number {
return sumBy(groups, (group) =>
sumBy(group.items, (item) => item.referencingNodes.length)
)
}

View File

@@ -16,6 +16,10 @@ import {
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import {
countMissingMediaReferences,
getMissingMediaReferences
} from './missingMediaGrouping'
import type { MissingMediaCandidate } from './types'
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
@@ -421,6 +425,11 @@ describe('groupCandidatesByName', () => {
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.referencingNodes[0]).toMatchObject({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image'
})
expect(photoGroup?.mediaType).toBe('image')
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
@@ -487,6 +496,27 @@ describe('groupCandidatesByMediaType', () => {
})
})
describe('missing media references', () => {
it('flattens references without deduping shared filenames', () => {
const groups = groupCandidatesByMediaType([
makeCandidate('1', 'shared.png'),
makeCandidate('2', 'shared.png'),
makeCandidate('3', 'other.png')
])
expect(groups).toHaveLength(1)
expect(groups[0].items).toHaveLength(2)
expect(countMissingMediaReferences(groups)).toBe(3)
expect(
getMissingMediaReferences(groups).map(({ nodeRef }) => nodeRef)
).toEqual([
expect.objectContaining({ nodeId: '1' }),
expect.objectContaining({ nodeId: '2' }),
expect.objectContaining({ nodeId: '3' })
])
})
})
describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'

View File

@@ -256,6 +256,7 @@ export function groupCandidatesByName(
if (existing) {
existing.referencingNodes.push({
nodeId: c.nodeId,
nodeType: c.nodeType,
widgetName: c.widgetName
})
} else {
@@ -263,7 +264,9 @@ export function groupCandidatesByName(
name: c.name,
mediaType: c.mediaType,
representative: c,
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
referencingNodes: [
{ nodeId: c.nodeId, nodeType: c.nodeType, widgetName: c.widgetName }
]
})
}
}

View File

@@ -67,18 +67,12 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('clearMissingMedia resets all state including interaction state', () => {
it('clearMissingMedia resets candidates and aborts verification', () => {
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()
@@ -87,9 +81,6 @@ describe('useMissingMediaStore', () => {
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', () => {
@@ -145,47 +136,6 @@ describe('useMissingMediaStore', () => {
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()
@@ -264,40 +214,6 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('cleans interaction state for removed names', () => {
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'
store.removeMissingMediaByNodeId('1')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('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.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('does nothing when candidates are null', () => {
const store = useMissingMediaStore()
store.removeMissingMediaByNodeId('1')
@@ -397,21 +313,5 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
})
it('clears interaction state for removed names not used elsewhere', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'shared.png'),
makeCandidate('65:80:5', 'shared.png'),
makeCandidate('65:70:64', 'only-interior.png')
])
store.pendingSelection['shared.png'] = 'library/shared.png'
store.pendingSelection['only-interior.png'] = 'library/interior.png'
store.removeMissingMediaByPrefix('65:70:')
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
})
})
})

View File

@@ -56,14 +56,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
)
})
// 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 {
@@ -84,58 +76,20 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
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 removeMissingMediaByNodeId(nodeId: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter((m) => String(m.nodeId) === nodeId)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => String(m.nodeId) !== nodeId
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
@@ -150,7 +104,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
*/
function removeMissingMediaByPrefix(prefix: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set<string>()
const remaining: MissingMediaCandidate[] = []
for (const m of missingMediaCandidates.value) {
// Preserve candidates without a nodeId; they cannot belong to any
@@ -160,19 +113,12 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
remaining.push(m)
continue
}
if (String(m.nodeId).startsWith(prefix)) {
removedNames.add(m.name)
} else {
if (!String(m.nodeId).startsWith(prefix)) {
remaining.push(m)
}
}
if (removedNames.size === 0) return
if (remaining.length === missingMediaCandidates.value.length) return
missingMediaCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingMedia(media: MissingMediaCandidate[]) {
@@ -193,9 +139,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
expandState.value = {}
uploadState.value = {}
pendingSelection.value = {}
}
return {
@@ -208,7 +151,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
setMissingMedia,
addMissingMedia,
removeMissingMediaByName,
removeMissingMediaByWidget,
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
@@ -216,10 +158,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia,
expandState,
uploadState,
pendingSelection
isContainerWithMissingMedia
}
})

View File

@@ -30,6 +30,7 @@ export interface MissingMediaViewModel {
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
nodeType?: string
widgetName: string
}>
}

View File

@@ -35,12 +35,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
let testId = 0
beforeEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
delete window.__comfyDesktop2
delete window.__comfyDesktop2Remote
})
describe('fetchModelMetadata', () => {
beforeEach(() => {
fetchMock.mockReset()
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
testId++
})
@@ -242,7 +247,126 @@ describe('downloadModel', () => {
beforeEach(() => {
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
})
it('uses the Desktop2 bridge directly instead of the browser fallback', () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(desktopDownloadModel).toHaveBeenCalledWith(
'https://huggingface.co/org/model/resolve/main/model.safetensors',
'model.safetensors',
'checkpoints'
)
expect(anchorClick).not.toHaveBeenCalled()
expect(mockStartDownload).not.toHaveBeenCalled()
})
it('logs Desktop2 bridge failures without falling back to browser download', async () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const bridgeError = new Error('bridge failed')
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockRejectedValue(bridgeError)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to start Desktop2 model download:',
bridgeError
)
})
expect(anchorClick).not.toHaveBeenCalled()
expect(mockStartDownload).not.toHaveBeenCalled()
})
it('logs synchronous Desktop2 bridge failures without crashing', async () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const bridgeError = new Error('bridge failed before returning a promise')
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockImplementation(() => {
throw bridgeError
})
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to start Desktop2 model download:',
bridgeError
)
})
expect(anchorClick).not.toHaveBeenCalled()
expect(mockStartDownload).not.toHaveBeenCalled()
})
it('keeps remote Desktop2 sessions on the browser fallback', () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2Remote = true
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(desktopDownloadModel).not.toHaveBeenCalled()
expect(anchorClick).toHaveBeenCalledTimes(1)
})
it('opens the model library sidebar before starting a desktop download', () => {

View File

@@ -3,6 +3,21 @@ import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
interface ComfyDesktop2Bridge {
downloadModel: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
}
declare global {
interface Window {
__comfyDesktop2?: ComfyDesktop2Bridge
__comfyDesktop2Remote?: boolean
}
}
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://civitai.red/',
@@ -35,6 +50,17 @@ export interface ModelWithUrl {
directory: string
}
async function startDesktop2ModelDownload(
bridge: ComfyDesktop2Bridge,
model: ModelWithUrl
): Promise<void> {
try {
await bridge.downloadModel(model.url, model.name, model.directory)
} catch (error: unknown) {
console.error('Failed to start Desktop2 model download:', error)
}
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
@@ -63,6 +89,12 @@ export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
const desktop2Bridge = window.__comfyDesktop2
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
void startDesktop2ModelDownload(desktop2Bridge, model)
return
}
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url

View File

@@ -208,6 +208,23 @@ describe('GtmTelemetryProvider', () => {
expect(entry!.error as string).toHaveLength(100)
})
it('pushes execution_start', () => {
const provider = createInitializedProvider()
provider.trackWorkflowExecution()
expect(lastDataLayerEntry()).toMatchObject({
event: 'execution_start'
})
})
it('pushes execution_success with job_id', () => {
const provider = createInitializedProvider()
provider.trackExecutionSuccess({ jobId: 'job-1' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'execution_success',
job_id: 'job-1'
})
})
it('pushes select_content for template events', () => {
const provider = createInitializedProvider()
provider.trackTemplate({

View File

@@ -59,8 +59,6 @@ import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ShareFlowMetadata,
SurveyResponses,
TemplateLibraryClosedMetadata,
@@ -288,8 +286,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
}
const enterLinearMetadata: EnterLinearMetadata = {}
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
const executionErrorMetadata: ExecutionErrorMetadata = { jobId: 'job-1' }
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
const authMetadata: AuthMetadata = {}
it.for<
@@ -355,16 +351,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
(p) => p.trackShareFlow(shareFlowMetadata),
TelemetryEvents.SHARE_FLOW
],
[
'trackExecutionError',
(p) => p.trackExecutionError(executionErrorMetadata),
TelemetryEvents.EXECUTION_ERROR
],
[
'trackExecutionSuccess',
(p) => p.trackExecutionSuccess(executionSuccessMetadata),
TelemetryEvents.EXECUTION_SUCCESS
],
[
'trackAuth',
(p) => p.trackAuth(authMetadata),
@@ -422,27 +408,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
})
)
})
it('trackWorkflowExecution forwards the latest trigger_source from trackRunButton', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
provider.trackRunButton({ trigger_source: 'keybinding' })
provider.trackWorkflowExecution()
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.EXECUTION_START,
expect.objectContaining({ trigger_source: 'keybinding' })
)
mockMixpanel.track.mockClear()
provider.trackWorkflowExecution()
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.EXECUTION_START,
expect.objectContaining({ trigger_source: 'unknown' })
)
})
})
describe('MixpanelTelemetryProvider — topup delegation', () => {

View File

@@ -18,10 +18,7 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -92,7 +89,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
private mixpanel: OverridedMixpanel | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
constructor() {
@@ -300,7 +296,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
@@ -420,24 +415,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = getExecutionContext()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}

View File

@@ -14,9 +14,6 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
@@ -102,7 +99,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private eventQueue: QueuedEvent[] = []
private pendingFirstAuthAt = new Map<string, string>()
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
private desktopEntryProps: DesktopEntryProps | null = null
@@ -400,7 +396,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
@@ -532,24 +527,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = getExecutionContext()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}

View File

@@ -28,10 +28,10 @@
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useImageQuiet } from '@/composables/useImageQuiet'
import { cn } from '@comfyorg/tailwind-utils'
const { name, previewUrl } = defineProps<{
@@ -63,5 +63,5 @@ const imageOptions = computed(() => ({
src: normalizedPreviewUrl.value ?? ''
}))
const { isReady, isLoading, error } = useImage(imageOptions)
const { isReady, isLoading, error } = useImageQuiet(imageOptions)
</script>

View File

@@ -38,6 +38,15 @@ describe('widgetStore', () => {
store.registerCustomWidgets({ INT: override })
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
})
it('does not throw when an extension returns null/undefined widgets', () => {
const store = useWidgetStore()
// Regression: a misbehaving extension can resolve getCustomWidgets() to
// nullish, which must not break app init. The `!` casts deliberately
// violate the non-null parameter type to simulate that untrusted input.
expect(() => store.registerCustomWidgets(undefined!)).not.toThrow()
expect(() => store.registerCustomWidgets(null!)).not.toThrow()
})
})
describe('inputIsWidget', () => {

View File

@@ -22,6 +22,11 @@ export const useWidgetStore = defineStore('widget', () => {
function registerCustomWidgets(
newWidgets: Record<string, ComfyWidgetConstructor>
) {
// Extensions are untrusted code: `getCustomWidgets` is typed to return
// `Record<string, ...>`, but in practice an extension can resolve it to
// null/undefined. Guard here so a single misbehaving custom node can't
// throw "Cannot convert undefined or null to object" and break app init.
if (!newWidgets) return
for (const [type, widget] of Object.entries(newWidgets)) {
customWidgets.value.set(type, widget)
}