Compare commits

...

47 Commits

Author SHA1 Message Date
dante01yoon
4991802e8b merge: FE-732 update (PR #12465 fix + #12417 follow-up) into m1-fe-integration 2026-05-27 01:00:48 +09:00
Dante
4acd50cd85 fix(widgets): refresh model assets and skip output refresh on asset-mode dropdown open (FE-732 fix) (#12465)
https://github.com/user-attachments/assets/1baf8283-8170-4d50-aa22-25c05598874d



Stacks on #12417 (FE-732). The isCloud guard removal in the M1 stack
(FE-731/FE-732) exposed two latent regressions on the inline
`FormDropdown` asset path; this PR fixes both.

## Symptoms (verified via CDP against `pr-3809.testenvs.comfy.org` cloud
BE and a local `synap5e/assets-m1 --enable-assets` OSS BE)

Reported by Simon in #m1-fe-integration testing against PR #12411:

1. **Model dropdown stale list** — `r` to refresh doesn't update the
dropdown; the model list is fetched once on first dropdown open
(`/api/assets?include_tags=models,checkpoints&limit=500&exclude_tags=missing`)
and cached. Reopening shows stale data. Newly imported models don't
appear until full page reload.
2. **`/api/jobs` per dropdown open** — each model dropdown open hits
`/api/jobs?status=completed,failed,cancelled&limit=200&offset=0` for no
reason. This is the OSS history path being triggered by the output-media
refresh hook in the dropdown.

## Root cause

- `useAssetWidgetData.watch(immediate:true)` is the only trigger for
model fetch; once `assetsStore.hasAssetKey(...)` returns true, the watch
short-circuits forever. Dropdown reopen has no refresh hook.
- `WidgetSelectDropdown.handleIsOpenUpdate` calls
`outputMediaAssets.refresh()` on every open regardless of widget kind.
For asset-mode (model) dropdowns this is irrelevant — but the call still
routes through `useAssetsApi('output')` → `assetsStore.updateHistory` →
`api.getHistory` → `/jobs?status=completed,failed,cancelled` on OSS.
Cloud distribution swaps the provider for `useFlatOutputAssets` which
hits `/api/assets?include_tags=output` — still wasted work, just a
different URL.

The handler and cache guard are pre-existing (#6734, #8090), but were
only reachable from cloud distributions before FE-731 unwrapped `if
(isCloud)` in `useAssetWidgetData`.

## Fix

- `useAssetWidgetData`: expose `refresh()` that re-invokes
`assetsStore.updateModelsForNodeType` for the current node type, guarded
against re-entry while a fetch is in flight.
- `WidgetSelectDropdown.handleIsOpenUpdate`: branch on
`props.isAssetMode` — asset-mode dropdowns refresh model assets via
`assetData.refresh()`; non-asset-mode dropdowns continue to refresh
`outputMediaAssets` as before (preserved cloud / OSS behavior).

## Verification

**CDP — Vue Nodes 2.0 ON**

Same dropdown opened twice (`qwen_image_vae` VAELoader widget):

| Open # | `/api/jobs?status=...` |
`/api/assets?include_tags=models,vae` |
|---|---|---|
| 1st | 0 | 1 (fresh fetch) |
| 2nd | 0 | 1 (fresh fetch — newly added models would appear) |

**Before the fix** (same setup): 1st open fired both `/api/jobs` and the
model fetch; 2nd open fired only `/api/jobs` (model list stale).

**Other paths preserved (verified empirically + by code)**:
- OSS + non-asset-mode dropdown → `/api/jobs` still fires (existing OSS
behavior).
- Cloud + non-asset-mode dropdown → `/api/assets?include_tags=output`
still fires (existing cloud behavior; branch in
`WidgetSelectDropdown.vue` outputMediaAssets ternary is untouched).
- WS-status queue/history polling (`limit=64` `/api/jobs`) still fires
on page load — unrelated to dropdown.

## Test plan

- [x] `pnpm vitest run
src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts`
— 10/10 (3 new tests for `refresh()`).
- [x] `pnpm vitest run
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts`
— 9/9 (4 new tests cover handleIsOpenUpdate branches: asset-mode skips
outputMediaAssets.refresh, asset-mode reopen refetches model assets,
non-asset-mode preserves outputMediaAssets.refresh, close event is
no-op).
- [x] `pnpm typecheck` clean.
- [ ] Re-test in `m1-fe-integration` once stacked PRs merge: import a
new model via cloud import flow → reopen dropdown → new model appears
without page reload.

Uploading Screen Recording 2026-05-27 at 12.43.13 AM.mov…
2026-05-27 00:59:13 +09:00
dante01yoon
02b8cc6882 test(assets): cover input-tab bulk Delete footer on OSS build (FE-732)
The pre-FE-732 sidebar logic hid the bulk Delete footer button on the
Imported tab when isCloud was false. After removing the gate the button
must render in both Cloud and OSS builds. Add a Playwright case that
runs on the default chromium project — i.e. the OSS build — to lock the
new invariant, mirroring the existing output-asset case in the same
describe block.
2026-05-22 23:33:34 +09:00
dante01yoon
752452b905 merge: FE-732 (PR #12417) into m1-fe-integration 2026-05-22 23:30:20 +09:00
dante01yoon
519aefd382 test(assets): tighten FE-732 coverage on context menu + filter bar
Lock the post-FE-732 contracts surfaced by the latent Boolean
prop-default-true fix and the unconditional filter rendering:

- MediaAssetContextMenu.test.ts — add an explicit
  showDeleteButton: false case so the opt-out path of the prop is
  asserted, complementing the input-mode default-true case from the
  parent commit. Reshape mountComponent to take an opts object.

- MediaAssetFilterBar.test.ts — new component test verifies the
  filter button renders unconditionally and that the settings menu
  receives show-sort-options=true after the constant pin.
2026-05-22 22:54:08 +09:00
dante01yoon
0a8f936f9d refactor(assets): remove isCloud guards on L1 UI surfaces (FE-732)
Remove isCloud guards from the filter bar, asset browser modal, sidebar
delete site, context-menu delete site, and input-asset delete throw —
all five UI surfaces enumerated in FE-732 now render and execute
unconditionally against the unified /api/assets surface.

Drop the dead cloud-only docstring on AssetBrowserModal.overrideAssets
and the dead shouldShowDeleteButton computed in AssetsSidebarTab (the
latter was the last caller of the :show-delete-button pass-through).
Fix the latent Vue Boolean prop-default-false trap in
MediaAssetContextMenu by defaulting showDeleteButton to true via
destructure default; the previous "propAllows = showDeleteButton ?? true"
never fired because Vue normalizes absent Boolean props to false. The
bug was masked while AssetsSidebarTab explicitly passed the prop.

Drop the now-unused mediaAsset.deletingImportedFilesCloudOnly i18n
key from all 12 locales.

Tests: add OSS + Cloud input-asset deletion paths in
useMediaAssetActions, add input-mode delete visibility coverage in
MediaAssetContextMenu, add an @oss describe block in
assetDeleteClearsLoadImage so both Playwright projects run the
deletion flow. Refresh stale cloud-only comments in the filter and
sort sidebar specs; their @cloud tags stay because /api/jobs is
still cloud-only.

L4 residual: filter-bar query params job_ids, asset_hash, and
include_public will start being accepted by OSS once BE-886 and
BE-891 merge — no FE change needed at that point.

Stacked on top of FE-731. Depends on BE-786 (OSS removes
--enable-assets) before this can ship to production.

- Fixes FE-732
2026-05-22 19:48:50 +09:00
dante01yoon
b50510f7d8 merge: FE-747 (PR #12399) into m1-fe-integration
# Conflicts:
#	src/platform/assets/utils/assetMetadataUtils.test.ts
#	src/platform/assets/utils/assetMetadataUtils.ts
2026-05-22 13:59:23 +09:00
dante01yoon
d8422691cc merge: FE-746 (PR #12398) Case B + soft-degrade + e2e onto m1-fe-integration 2026-05-22 13:56:16 +09:00
dante01yoon
15108848d5 test(missingMedia): cover file_path union + soft-degrade in browser tests (FE-746)
Adds an e2e regression spec for the FE-746 behavior changes that unit
tests can only assert with mocks:

1. Case B union — a workflow stored before BE-933/934 keeps its widget
   value as a bare filename. Once the backend starts emitting `file_path`
   on the same asset (a namespace-rooted locator that diverges from
   `name`), detection must still match via the `name` arm. The
   pre-correction Case A early-return would false-negative here.
2. BE-933 hash-only registration — assets registered via
   POST /assets/from-hash come back with `file_path: null` and
   `display_name: null`. The legacy `name` arm has to keep working
   through the BE-792 deprecation window.
3. Negative path — a listing that does not cover the widget value via
   any key still surfaces the missing-media overlay. Guards against an
   accidental "match everything" regression when the early-return was
   removed.
4. Soft-degrade — when `/api/assets` returns 500 (pre-BE-786 OSS without
   the endpoint, partial BE-934 deploys, transient errors),
   `Promise.allSettled` lets the verifier finish and mark the candidate
   missing instead of leaving `isMissing` stuck at undefined behind a
   silent toast.

`Asset` from `@comfyorg/ingest-types` does not yet carry `file_path` /
`display_name` (regen is BE-932). Spec extends the type locally so the
mocked wire shape stays strongly typed.

The companion workflow asset is the minimum LoadImage graph needed to
fire the missing-media pipeline; widget value is a fixed sentinel
filename used by every test.
2026-05-22 13:17:12 +09:00
dante01yoon
076cba9611 refactor(missingMedia): soft-degrade independent asset oracles (Promise.allSettled)
`resolveMissingMediaAssetSources` previously ran the input-asset fetch
(`/api/assets`) and the generated-asset fetch (Cloud `/api/assets` or
OSS `/history`) under `Promise.all` with `abortSiblingsOnFailure`, so a
single rejection took down both. With the FE-746 unification, the input
fetch now always hits `assetService.getInputAssetsIncludingPublic` —
including against backends that may legitimately fail (pre-BE-786 OSS
without `/api/assets`, BE-934 partial deploys, transient network errors).

Switch to `Promise.allSettled` and per-branch soft-degrade. Each oracle
is independent; failure in one shouldn't cancel the other. On rejection,
return an empty list from that branch; abort errors stay silent, real
errors get a one-line console warning so we can spot them without
swallowing the rest of the verification.

Caller (`verifyMediaCandidates`) already treats an empty asset list as
"nothing matches this candidate," which surfaces affected candidates as
missing rather than silently dropping them via the previous toast-warn
path. False-positive missing is recoverable (user sees the issue in the
Errors tab); silent-fail was not.

Adversarial review (Codex) flagged the cascade. RFC §4's BEST EFFORT
framing for `file_path` already implies the contract has soft-fail
expectations, so this aligns the resolver shape with the RFC's stated
tolerance.

Removes the now-unused `abortSiblingsOnFailure` helper. Replaces the
abort-cascade regression test with two independence tests covering each
oracle failing in isolation.
2026-05-22 12:53:14 +09:00
dante01yoon
37f9a14e15 refactor(missingMedia): union file_path with legacy detection keys (Case B)
`getAssetDetectionNames` previously short-circuited on `asset.file_path`,
returning only that key when emitted. That premise — `file_path` as the
primary match key — doesn't match RFC BE-808 v2: §3 names `id` as the
identity field and §4 explicitly calls `file_path` "a standalone
locator/display string" emitted on a BEST EFFORT basis.

Workflow widget values predate the BE-933 / BE-934 `file_path` rollout
and may still be bare filenames, hashes, or annotated paths. A pre-BE
workflow paired with a post-BE asset would false-negative under the
early-return: the asset emits `file_path: "input/sub/photo.png"` while
the widget value is still `"photo.png"`, and the previous code would
mark the reference missing even though the file is present.

Union `file_path`, `asset_hash`, `name`, and `subfolder + name` variants
so workflows authored against any historical asset shape keep matching.
Both backends round-trip `name` through the BE-792 deprecation window,
so the legacy keys stay valid for the duration of the rollout.

Adversarial review (Codex) flagged the false-negative; RFC re-read
confirmed `id` (not `file_path`) is the identity field. Plan and
docblock terminology updated to drop the misleading "canonical match
key" framing.

Adds a regression test for the bare-filename × file_path-emitting asset
case.
2026-05-22 12:30:19 +09:00
Dante
bec9e3714d Merge branch 'main' into jaewon/fe-746-l3-fe-migrate-missingmediascants-to-unified-file_path-based 2026-05-22 11:53:09 +09:00
dante01yoon
d45e64197c merge: FE-750 (PR #12318) into m1-fe-integration 2026-05-22 09:06:25 +09:00
dante01yoon
4b0f6dc293 merge: FE-749 (PR #12328) into m1-fe-integration 2026-05-22 09:06:21 +09:00
dante01yoon
117d011900 merge: FE-746 (PR #12398) into m1-fe-integration 2026-05-22 09:05:30 +09:00
dante01yoon
c244b53d0b merge: FE-731 (PR #12375) into m1-fe-integration 2026-05-22 09:05:25 +09:00
dante01yoon
4837307fa6 merge: FE-730 (PR #12335) into m1-fe-integration 2026-05-22 09:05:06 +09:00
dante01yoon
3051c00254 merge: FE-729 (PR #12322) into m1-fe-integration 2026-05-22 09:04:02 +09:00
dante01yoon
77514a4c82 merge: FE-733 (PR #12287) into m1-fe-integration 2026-05-22 09:03:57 +09:00
dante01yoon
2fd67256fd docs(assets): tighten getMediaDisplayName and getAssetDisplayFilename docstrings 2026-05-21 20:21:57 +09:00
dante01yoon
e35bb25d7c test(missingMedia): cover getLibraryOptions integration with getMediaDisplayName
Adds 4 cases against the in-file consumer of getMediaDisplayName that was
previously untested: empty-on-missing-widget, Cloud-hash combo values
mapped through the shared helper chain (display_name and
metadata.filename), OSS filename pass-through, and the candidate-name
filter. Caught by the FE-747/748 PR review pass.
2026-05-21 16:11:21 +09:00
dante01yoon
2184d25dcb refactor(assets): consume unified display_name across helpers (FE-747, FE-748)
FE-748: drop the isCloud branch in getMediaDisplayName. Resolve through
the shared getAssetDisplayFilename helper so the missingMedia surface
uses the same fallback chain (user_metadata.filename ->
metadata.filename -> display_name -> asset.name) as the asset card and
browser surfaces.

FE-747: align assetMetadataUtils helper docstrings with the unified
display_name shape emitted by both Core and Cloud per the BE-808 Asset
Identity RFC. Add fixture tests covering the Cloud (hash + display_name)
and OSS (filename + nullable display_name) response shapes.
2026-05-21 16:00:29 +09:00
dante01yoon
98642301fe refactor(missingMedia): adopt file_path as primary detection key (FE-746)
- Add `file_path` to AssetItem schema (nullable per BE-933/BE-934 wire shape)
- Rewrite `getAssetDetectionNames`: use `file_path` alone when emitted,
  fall back to the legacy `asset_hash` / `name` / `subfolder + name` union
  when null (hash-only Core registrations, tagless Cloud rows, legacy data)
- Drop `isCloud` parameter from `scanAllMediaCandidates`,
  `scanNodeMediaCandidates`, and `MediaVerificationOptions` — both backends
  consult the same asset listing oracle post-BE-933/934
- Remove the OSS synchronous-shortcut branch; all candidates now resolve
  asynchronously through `verifyMediaCandidates` against the unified listing
- Rename `MediaVerificationOptions.isCloud` to `allowCompactSuffix`
  (N1-deferred annotation grammar flag, no longer a backend identity field)
- Always call `assetService.getInputAssetsIncludingPublic`; retain internal
  `isCloud` only for the generated-assets oracle (Cloud /api/assets vs OSS
  history) — unifying that path is tracked separately
- Update callers in app.ts, useErrorClearingHooks.ts, markDeletedAssetsAsMissingMedia.ts
- Update tests with module-level `isCloud` mock for generated-asset oracle
  control; add `file_path`-primary matching test case

Per RFC BE-808 v2 (Asset Identity Semantics). DO NOT MERGE until BE-933
(comfyanonymous/ComfyUI#14005) and BE-934 (Comfy-Org/cloud#3744) merge
and ship — both backends must emit `file_path` for the new path to fire.

Fixes FE-746
2026-05-21 14:29:12 +09:00
dante01yoon
55b0329203 test(builder): use exclusive fallback instead of .or() in selectInputWidget
Previous .or() implementation acted as a union — both branches resolved
for number widgets (e.g. 'seed' has aria-label on wrapper + a Decrement
button under widget-layout-field-label), causing Playwright strict mode
violations on tests that previously passed.

Switch to a count-based check: prefer the aria-label match when it
exists, otherwise fall back to the dropdown-trigger button under the
visible label row. This restores grid/number widget behaviour while
keeping asset-mode dropdown support.
2026-05-21 09:43:38 +09:00
GitHub Action
c9d9ee1ee5 [automated] Apply ESLint and Oxfmt fixes 2026-05-20 09:58:37 +00:00
dante01yoon
9b9b0f2457 refactor(assets): remove isCloud guards in widget composables (FE-731)
- useComboWidget: drop the `if (isCloud)` wrap so shouldUseAssetBrowser
  and NODE_MEDIA_TYPE_MAP paths run unconditionally. Rename
  resolveCloudDefault -> resolveAssetDefault.
- useAssetWidgetData: drop the `if (isCloud)` branch and its empty-data
  fallback; remove the "Cloud-only" docstring.
- Tests: drop the isCloud mock + per-test toggles. Delete the
  isCloud=false desktop spec and the OSS LoadImage fall-through case
  that asserted the now-removed branch.
2026-05-20 18:54:07 +09:00
GitHub Action
c579c88e90 [automated] Apply ESLint and Oxfmt fixes 2026-05-20 04:03:11 +00:00
dante01yoon
0797b7af7a test(builder): make selectInputWidget work in both grid and asset modes
FE-729 routes model widgets (ckpt_name, lora_name, etc.) through
WidgetSelectDropdown on OSS as well as Cloud. That dropdown's trigger
button has no aria-label — the widget name lives in a sibling label
div ([data-testid="widget-layout-field-label"]). Grid-mode widgets
(WidgetSelectDefault) still carry aria-label on the input directly.

Update the helper to .or() the two strategies so existing aria-label
lookups keep working while asset-mode dropdowns are reached via the
visible label text + sibling trigger button.
2026-05-20 12:59:06 +09:00
GitHub Action
1d71c93cad [automated] Apply ESLint and Oxfmt fixes 2026-05-20 03:34:08 +00:00
dante01yoon
b38fab4f66 refactor(assets): remove isCloud forks in assetsStore + delete legacy fetcher
FE-730 (L1.3). Both Cloud and OSS will serve /api/assets post-BE-786,
so assetsStore collapses to a single asset-API code path.

- Drop fetchInputFilesFromAPI (legacy /files/input caller); rename
  fetchInputFilesFromCloud to fetchInputFiles.
- Remove the isCloud ternary on the input fetcher swap.
- Unwrap getModelState()'s if(isCloud) body and delete its OSS no-op
  return object; the model pagination subsystem now runs unconditionally.
- Drop now-dead isCloud + mapInputFileToAssetItem imports.
- Delete mapInputFileToAssetItem + stripDirectoryAnnotation from
  assetMappers.ts (no remaining callers in src/); delete its test file.
- Simplify assetsStore.test.ts: hardcode the distribution mock to
  isCloud: true, drop the mockIsCloud toggle helper and the dead
  mapper mock, rename the now-unconditional "(Cloud)" describe blocks.

Blocked-merge by BE-786 (OSS --enable-assets removal) + FE-729
(isAssetAPIEnabled deletion). Opened as a Draft on the FE-729 stack.

Auto-fixed unrelated tailwind class-order lint errors in five files
via pnpm lint:fix to keep CI green.
2026-05-20 12:29:41 +09:00
dante01yoon
ffe8d0f2ee test(assets): drop OSS legacy sidebar tree tests (FE-729)
After FE-729 the model-library sidebar tab routes to the Asset Browser
dialog and never renders ModelLibrarySidebarTab.vue. The legacy
sidebar-tree tests now validate dead code paths:

- delete browser_tests/tests/sidebar/modelLibrary.spec.ts entirely
  (tab/folders/search/refresh/empty-state — all dead)
- drop the model-library entry from the defaultKeybindings sidebar
  toggle table (KeyM no longer toggles a panel)
- remove the Refresh-clears-resolved-missing-model case from
  errorsTabMissingModels.spec.ts — it waited for the legacy
  /experiment/models endpoint that modelStore no longer hits

Dialog-flow coverage for the new entry point belongs to FE-732.
2026-05-20 08:42:10 +09:00
Dante
ec0711d62e Merge branch 'main' into jaewon/fe-729-delete-is-asset-api-enabled 2026-05-19 22:29:44 +09:00
dante01yoon
e48dcd11f1 ci(temp): enable --enable-assets on Playwright ComfyUI server
Temporary unblock for FE-729 e2e while BE-786 is in flight. FE-729
removes the legacy /api/models fallback in modelStore, so the assets
endpoint must be reachable in CI. BE-786 will make assets always-on
in OSS core; once that ships in the CI ComfyUI image, this flag (and
this commit) MUST be reverted before merging FE-729 to main.
2026-05-19 22:14:38 +09:00
Matt Miller
5b1446a794 refactor(asset-card): extract metadata dimension helper to utils
Move isValidDimension + the metadata-vs-fallback logic into
getAssetMetadataDimensions in assetMetadataUtils, leaving the SFC with a
one-line computed. Switches the block comments to JSDoc on the helper
(per review feedback) and adds unit coverage for the validation cases —
addresses codecov/patch failure on the previously SFC-internal logic.
2026-05-18 21:22:54 -07:00
Matt Miller
2a5de94145 fix(asset-card): validate metadata dimensions and clarify fallback naming
Tightens the metadata-dimension read added in the previous commit
based on review feedback:

- Adds isValidDimension() guard: rejects NaN, Infinity, 0, negatives,
  and fractional values. typeof v === 'number' alone accepts all of
  those, any of which would render as garbage in the dimension label
  (e.g. "NaNxNaN", "0x0", "1024.5x768").
- Renames originalImageDimensions to displayImageDimensions. The
  fallback branch may return preview-sized values from the rendered
  <img>'s naturalWidth/Height — calling that "original" invites the
  next caller to wire it into a place where source-of-truth
  dimensions are required.
- Restores meaning of metaInfo's existing truthy check by allowing
  displayImageDimensions to return undefined when neither path
  produces usable values (e.g. invalid metadata + image not yet
  loaded). Previously the truthy check was dead code because the
  computed always returned something.

No behavior change in the happy path (valid metadata, valid local).
2026-05-18 20:23:14 -07:00
Matt Miller
c3cde8dd6a refactor(asset-card): read image dimensions from typed metadata field
Asset cards now render image dimensions from the asset response's
metadata field (`asset.metadata.width` / `asset.metadata.height`)
when available, falling back to the locally-computed naturalWidth /
naturalHeight from the rendered <img> tag when not. This unifies
the dimension display across runtimes that serve the original file
vs. runtimes that serve a downscaled preview — the rendered <img>
on the latter reports the preview size, not the source asset's
size, so a metadata-first read is what makes the displayed label
match the source on both runtimes.

No behavior change in the absence of metadata: cards continue to
display the locally-computed dimensions as they do today.

Drops the now-unused isCloud import; the conditional it guarded
collapses into the unified read-then-fallback path.
2026-05-18 20:15:09 -07:00
GitHub Action
09943f855a [automated] Apply ESLint and Oxfmt fixes 2026-05-19 01:30:57 +00:00
dante01yoon
b34026527a refactor(assets): remove isAssetPreviewSupported wrapper and simplify callers
Follow-up to the previous FE-729 commit. After deleting
isAssetAPIEnabled, isAssetPreviewSupported() became a wrapper that
always returned true. Remove the function and simplify all callers.

Changes:
- Delete isAssetPreviewSupported() from assetPreviewUtil.ts.
- Media3DTop.vue: drop the isAssetPreviewSupported() arm of the
  loadThumbnail guard (asset.name check is still required).
- saveMesh.ts: unwrap two `if (isAssetPreviewSupported()) { ... }`
  blocks in applySaveGLBOutput and the SaveGLB beforeRegisterNodeDef
  extension callback.
- FormDropdownMenuItem.vue: drop the early return from
  resolveMeshPreview.
- useLoad3d.ts: drop the isAssetPreviewSupported() arm of the
  modelReady guard.
- Tests: remove the dead "asset preview is unsupported" branches
  (useLoad3d, Media3DTop, FormDropdownMenuItem) and clean up the
  associated mocks and hoisted state.

Auto-fixed unrelated tailwind class-order lint errors in five files
(VirtualGrid, RightSidePanel, Textarea, ModelInfoPanel,
WidgetSelectDefault) to keep CI green.
2026-05-19 10:27:08 +09:00
GitHub Action
fcdc4404eb [automated] Apply ESLint and Oxfmt fixes 2026-05-19 00:48:00 +00:00
dante01yoon
7f6d354a8e refactor(assets): delete isAssetAPIEnabled and Comfy.Assets.UseAssetAPI
FE-729 (L1.2 — L1 central). Asset API will be always available
post-BE-786 (`--enable-assets` removed from OSS). Delete the gating
function, the user-facing setting, the experimental warning, and the
toggle command. Simplify all caller sites to assume the asset API
is available.

Changes:
- Delete isAssetAPIEnabled() from assetService.ts; simplify
  shouldUseAssetBrowser() to call isAssetBrowserEligible() directly.
- Remove the EXPERIMENTAL_WARNING prefix from four asset-load error
  messages.
- Drop the Comfy.Assets.UseAssetAPI setting from coreSettings.ts and
  its schema entry in apiSchema.ts.
- Remove the Comfy.ToggleAssetAPI command outright. Keep
  Comfy.BrowseModelAssets but drop its setting-check / setting-set
  preamble and the "Experimental:" label prefix; the command now
  just opens the asset browser dialog.
- modelStore: drop the useAssetAPI fork in createGetModelsFunc and
  loadModelFolders; always route through assetService.
- sidebarTabStore: model-library tab click always routes through
  Comfy.BrowseModelAssets (no more setting check).
- assetPreviewUtil.isAssetPreviewSupported() returns true
  unconditionally; caller-site simplification deferred.
- WidgetSelect.vue: simplify isAssetMode to drop the
  isAssetAPIEnabled() arm of the disjunction.
- Test mocks/expectations updated.

Blocked-merge by BE-786 (OSS `--enable-assets` removal). PR opened
as draft.

Also auto-fixed unrelated tailwind class-order lint errors in four
files (VirtualGrid, RightSidePanel, Textarea, ModelInfoPanel,
WidgetSelectDefault) to keep CI green.
2026-05-19 09:43:27 +09:00
Matt Miller
7f9804e2bd refactor: use UploadImageResponse type from @comfyorg/ingest-types
Replace the hand-rolled { name?: string ... } shapes in the painter
and mask editor with UploadImageResponse from the codegen'd
ingest-types package. The runtime shape is unchanged - the type now
flows from the spec via the existing codegen pipeline instead of
being declared inline locally.

The runtime check for data.name remains: the spec marks the 200
response properties as optional, so the imported type still has
name?: string. The check enforces what the server actually returns
in practice; tightening the spec is a separate follow-up.
2026-05-18 17:08:19 -07:00
Matt Miller
5809f55171 test: drop dead /upload/mask interceptors from maskEditor e2e
After the upload-contract unification, the mask editor saves all four
layers through /upload/image. The Playwright route interceptors for
/upload/mask in the save-success and save-failure tests no longer
fire.

- save-success: remove the mask interceptor, tighten the count assertion
  from "> 0" to "=== 4" (one upload per layer)
- save-failure: drop the unused mask interceptor; the image-route 500
  is sufficient to keep the dialog open
2026-05-18 15:48:24 -07:00
Matt Miller
29a86503fc Merge branch 'main' into matt/fe-750-unify-image-editor-upload-contract 2026-05-18 15:36:09 -07:00
Matt Miller
45402c6b82 fix: throw on malformed upload response instead of silent stale ref
Both uploadLayer (maskeditor) and the painter's serialize path previously
fell through to the pre-upload layer.ref when the server returned 200 with
no name or with a non-JSON body, logging only a console.warn. The caller
then assigned the stale ref onto the saved widget value as if the upload
had succeeded, producing graphs that pointed at files that were never
written.

- maskeditor: rewrite uploadLayer to throw on JSON parse failure and on
  missing data.name (was: console.warn + return layer.ref)
- painter: add a missing-name guard alongside the existing JSON parse
  guard, surfacing the same toast/error path the JSON failure already uses
- painter test: assert the upload FormData carries type=input with no
  subfolder, and add coverage for the two new error branches
2026-05-18 15:35:26 -07:00
Matt Miller
e927025ee3 refactor: unify image editor upload contract
Collapse the OSS vs Cloud branching in the mask editor and painter so both
runtimes use the same upload contract: POST to /upload/image with type "input"
and no subfolder, then reference the result by filename only.

- maskeditor: replace separate uploadMask + uploadImage helpers with a single
  uploadLayer; drop the /upload/mask call, the original_ref form field, and
  the clipspace subfolder
- painter: drop the isCloud branch on type/subfolder/result string; always
  upload as type "input" with no subfolder
- update painter test to match the unified result shape
2026-05-18 15:25:05 -07:00
GitHub Action
48883269cc [automated] Apply ESLint and Oxfmt fixes 2026-05-15 09:08:32 +00:00
dante01yoon
c5dd38f089 refactor(assets): extract getAssetStoredFilename helper, add mockFeatureFlags test util
L1 prerequisite cleanup. Two type-preserving refactors that make
subsequent L1 sub-issues mechanical.

1. Extract getAssetStoredFilename(asset) helper to collapse the
   duplicated `isCloud && asset.asset_hash ? asset.asset_hash :
   asset.name` branch from useMediaAssetActions (lines 302, 446)
   into a single helper. Once BE-933/934 emit file_path and the
   cloud spec sync brings it into generated types, only the helper
   changes.

2. Add mockFeatureFlags(overrides) test utility under src/test-utils/.
   L1 sub-issues will use this consistently instead of re-inventing
   the mock shape each time. Includes a FeatureFlags type export
   from useFeatureFlags.ts so the mock stays in sync.

Also auto-fixed unrelated tailwind class-order lint errors in four
files (VirtualGrid, RightSidePanel, Textarea, ModelInfoPanel) to
keep CI green.

No behavior change.
2026-05-15 18:04:19 +09:00
85 changed files with 2210 additions and 1939 deletions

View File

@@ -19,5 +19,8 @@ runs:
run: |
set -euo pipefail
cp -r ./tools/devtools/* /ComfyUI/custom_nodes/ComfyUI_devtools/
cd /ComfyUI && python3 main.py --cpu --multi-user --front-end-root "${{ inputs.front_end_root }}" &
# TODO(FE-729): remove --enable-assets once BE-786 lands in the CI ComfyUI image
# (BE-786 removes the gate so /api/assets is always on). Until then, FE-729
# routes modelStore through assetService, which 503s without this flag.
cd /ComfyUI && python3 main.py --cpu --multi-user --enable-assets --front-end-root "${{ inputs.front_end_root }}" &
wait-for-it --service 127.0.0.1:8188 -t ${{ inputs.timeout }}

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"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": ["fe746_photo.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -119,9 +119,22 @@ export class BuilderSelectHelper {
)[0]
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
await nodeRef.centerOnNode()
const widgetLocator = this.comfyPage.vueNodes
.getNodeLocator(String(nodeRef.id))
.getByLabel(widgetName, { exact: true })
const node = this.comfyPage.vueNodes.getNodeLocator(String(nodeRef.id))
// Grid-mode widgets (WidgetSelectDefault) and number widgets expose
// aria-label on a wrapper/input. Asset-mode widgets (WidgetSelectDropdown)
// do not — the widget name lives in a sibling
// [data-testid="widget-layout-field-label"] div, so fall back to clicking
// the dropdown trigger button in the same row.
const byAriaLabel = node.getByLabel(widgetName, { exact: true })
const widgetLocator =
(await byAriaLabel.count()) > 0
? byAriaLabel
: node
.getByTestId('widget-layout-field-label')
.filter({ hasText: widgetName })
.locator('..')
.locator('button')
.first()
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
await widgetLocator.click({ force: true })
await this.comfyPage.nextFrame()

View File

@@ -2,13 +2,15 @@
* FE-230: Deleting an asset must clear the Load Image node preview, widget
* value, and mark the workflow dirty.
*
* Local run (requires cloud build of the frontend):
* pnpm build:cloud
* pnpm exec playwright test --project=cloud \
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
* FE-732: Input-asset deletion is no longer gated on `isCloud`; the same
* teardown flow now applies to both Cloud and OSS builds. Cloud and OSS
* variants below cover both Playwright projects against the shared mock.
*
* The cloud project is required because input-asset deletion is gated on
* `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`).
* Local run examples:
* pnpm build:cloud && pnpm exec playwright test --project=cloud \
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
* pnpm build && pnpm exec playwright test --project=chromium \
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
*/
import type { Page, Route } from '@playwright/test'
import { expect } from '@playwright/test'
@@ -119,81 +121,88 @@ const baseTest = comfyPageFixture.extend<{ assetMock: AssetMockApi }>({
}
})
function registerDeleteFlowTest() {
baseTest(
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
async ({ comfyPage, assetMock }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
// Drive the production drag-and-drop flow to point the Load Image
// widget at the asset we are about to delete and populate the preview
// cache. FE-230 is asserting that the deletion tears these down.
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
dropPosition: { x, y },
waitForUpload: true
})
const imageWidget = await loadImageNode.getWidget(0)
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
// Re-baseline the change tracker so the deletion-side mutation is the
// only thing that can flip `isModified` later.
await comfyPage.page.evaluate(() => {
const tracker =
window.app?.extensionManager?.workflow?.activeWorkflow?.changeTracker
tracker?.reset?.()
})
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
// Drive the real production flow: assets sidebar → Imported tab →
// right-click asset card → Delete → confirm dialog.
const sidebar = comfyPage.menu.assetsTab
// The default `open()` waits for assets on the Generated tab; we seed
// only an input asset, so skip that wait and let `waitForAssets(1)`
// gate on the Imported tab instead.
await sidebar.open({ waitForAssets: false })
await sidebar.switchToImported()
await sidebar.waitForAssets(1)
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
const deleteMenuItem = sidebar.contextMenuItem('Delete')
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await comfyPage.confirmDialog.click('delete')
// Mocked DELETE was issued.
await expect
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
.toBe(true)
// Widget value was cleared.
await expect.poll(() => imageWidget.getValue()).toBe('')
// Preview cache was cleared.
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
return node?.imgs?.length ?? 0
}, loadImageNode.id)
)
.toBe(0)
// Workflow was marked dirty by changeTracker.captureCanvasState().
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
}
)
}
baseTest.describe(
'FE-230 asset delete clears Load Image preview',
'FE-230 asset delete clears Load Image preview (cloud)',
{ tag: '@cloud' },
() => {
baseTest(
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
async ({ comfyPage, assetMock }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
// Drive the production drag-and-drop flow to point the Load Image
// widget at the asset we are about to delete and populate the preview
// cache. FE-230 is asserting that the deletion tears these down.
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
dropPosition: { x, y },
waitForUpload: true
})
const imageWidget = await loadImageNode.getWidget(0)
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
// Re-baseline the change tracker so the deletion-side mutation is the
// only thing that can flip `isModified` later.
await comfyPage.page.evaluate(() => {
const tracker =
window.app?.extensionManager?.workflow?.activeWorkflow
?.changeTracker
tracker?.reset?.()
})
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
// Drive the real production flow: assets sidebar → Imported tab →
// right-click asset card → Delete → confirm dialog.
const sidebar = comfyPage.menu.assetsTab
// The default `open()` waits for assets on the Generated tab; we seed
// only an input asset, so skip that wait and let `waitForAssets(1)`
// gate on the Imported tab instead.
await sidebar.open({ waitForAssets: false })
await sidebar.switchToImported()
await sidebar.waitForAssets(1)
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
const deleteMenuItem = sidebar.contextMenuItem('Delete')
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await comfyPage.confirmDialog.click('delete')
// Mocked DELETE was issued.
await expect
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
.toBe(true)
// Widget value was cleared.
await expect.poll(() => imageWidget.getValue()).toBe('')
// Preview cache was cleared.
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
return node?.imgs?.length ?? 0
}, loadImageNode.id)
)
.toBe(0)
// Workflow was marked dirty by changeTracker.captureCanvasState().
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
}
)
}
registerDeleteFlowTest
)
baseTest.describe(
'FE-230 asset delete clears Load Image preview (oss)',
{ tag: '@oss' },
registerDeleteFlowTest
)

View File

@@ -27,7 +27,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const

View File

@@ -218,21 +218,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}) => {
const dialog = await maskEditor.openDialog()
let maskUploadCount = 0
let imageUploadCount = 0
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-mask-${maskUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadCount++
return route.fulfill({
@@ -252,20 +239,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeHidden()
// The save pipeline uploads multiple layers (mask + image variants)
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
// through the unified /upload/image endpoint.
expect(
maskUploadCount + imageUploadCount,
'save should trigger upload calls'
).toBeGreaterThan(0)
imageUploadCount,
'save should upload all four layers via /upload/image'
).toBe(4)
})
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
route.fulfill({ status: 500 })
)
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill({ status: 500 })
)

View File

@@ -0,0 +1,197 @@
import type { Page } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { WorkspaceStore } from '@e2e/types/globals'
// BE-933 / BE-934 add `file_path` (and BE-933 also `display_name`) to the asset
// wire shape. `@comfyorg/ingest-types` is not yet regenerated from the updated
// OpenAPI (tracked under BE-932); extend the local type so mocks can carry the
// post-BE field without an `any` cast.
type PostBEAsset = Asset & {
file_path?: string | null
display_name?: string | null
}
const WORKFLOW_WIDGET_VALUE = 'fe746_photo.png'
async function mockAssetListing(
page: Page,
assets: PostBEAsset[]
): Promise<void> {
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const response: ListAssetsResponse = {
assets: assets as Asset[],
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}
async function mockAssetListingFailure(
page: Page,
status: number
): Promise<void> {
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify({ detail: `forced ${status} for FE-746 test` })
})
})
}
async function getCachedMissingMediaNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
test.describe(
'Missing media detection — file_path union (FE-746)',
{ tag: '@cloud' },
() => {
test.use({
initialSettings: {
'Comfy.RightSidePanel.ShowErrorsTab': true
}
})
test('does not surface missing media when a post-BE asset emits file_path that diverges from the workflow widget value (Case B regression)', async ({
comfyPage
}) => {
// BE-933 / BE-934 post-deploy shape: asset emits a namespace-rooted
// file_path that differs from the bare `name` the user originally chose.
// The workflow widget value (`fe746_photo.png`) predates the rollout, so
// it must still match via the `name` arm of the detection-key union.
// Case A (file_path-only early return) would mark this as missing.
await mockAssetListing(comfyPage.page, [
{
id: 'fe746-asset-1',
name: WORKFLOW_WIDGET_VALUE,
asset_hash: 'blake3:fe7460000000000000000000000000000',
file_path: 'input/sub/fe746_photo.png',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-22T00:00:00Z',
updated_at: '2026-05-22T00:00:00Z',
last_access_time: '2026-05-22T00:00:00Z'
}
])
await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
})
test('matches via legacy `name` fallback when the asset has no file_path (BE-933 hash-only registration)', async ({
comfyPage
}) => {
// BE-933 hash-only null case: asset registered via POST /assets/from-hash
// has no on-disk path, so `file_path` (and `display_name`) come back null.
// Detection must still succeed via the legacy `name` arm.
await mockAssetListing(comfyPage.page, [
{
id: 'fe746-asset-hash-only',
name: WORKFLOW_WIDGET_VALUE,
asset_hash: 'blake3:fe7460000000000000000000000000001',
file_path: null,
display_name: null,
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-22T00:00:00Z',
updated_at: '2026-05-22T00:00:00Z',
last_access_time: '2026-05-22T00:00:00Z'
}
])
await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
})
test('surfaces missing media when no asset in the listing covers the widget value', async ({
comfyPage
}) => {
// Sanity: with the union still in place, an asset listing that does not
// include the widget value via any key (file_path / asset_hash / name)
// must still report missing. Guards against accidental "match
// everything" regressions when the early-return was removed.
await mockAssetListing(comfyPage.page, [
{
id: 'fe746-unrelated-asset',
name: 'unrelated.png',
asset_hash: 'blake3:fe7460000000000000000000000000002',
file_path: 'input/unrelated.png',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-22T00:00:00Z',
updated_at: '2026-05-22T00:00:00Z',
last_access_time: '2026-05-22T00:00:00Z'
}
])
await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)
await expect
.poll(() => getCachedMissingMediaNames(comfyPage))
.toContain(WORKFLOW_WIDGET_VALUE)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeVisible()
})
test('soft-degrades when /api/assets fails so verification does not deadlock pending candidates', async ({
comfyPage
}) => {
// Promise.allSettled + per-branch soft-degrade (Finding 2): when the
// input-asset oracle fails (pre-BE-786 OSS without /api/assets, partial
// BE-934 deploys, transient network errors), the verifier must finish
// — marking the candidate missing — rather than leaving isMissing
// stuck at undefined behind a silent toast.
await mockAssetListingFailure(comfyPage.page, 500)
await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)
await expect
.poll(() => getCachedMissingMediaNames(comfyPage))
.toContain(WORKFLOW_WIDGET_VALUE)
})
}
)

View File

@@ -115,42 +115,5 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
comfyPage.page.getByTestId(TestIds.dialogs.missingModelRefresh)
).toBeVisible()
})
test('Should clear resolved missing model when Refresh is clicked', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await comfyPage.page.route(/\/object_info$/, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
const objectInfoResponse = comfyPage.page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
})
})
})

View File

@@ -8,13 +8,14 @@ import type {
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// The assets sidebar's media-type filter menu only renders in cloud mode
// (`MediaAssetFilterBar.vue` gates `MediaAssetFilterButton` behind `isCloud`).
// We tag tests `@cloud` so they run against the cloud Playwright project,
// and register both `/api/assets` and `/api/jobs` route handlers as auto
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
// Post-FE-732 the media-type filter menu renders unconditionally on both
// Cloud and OSS builds. These tests keep the `@cloud` tag because the
// `/api/jobs` dependency is still cloud-only; once OSS exposes equivalent
// jobs data we can drop the tag. Auto fixtures register `/api/assets` and
// `/api/jobs` route handlers — Playwright runs auto fixtures before the
// `comfyPage` fixture's internal `setup()`, so the page first-loads with
// mocks already in place. See cloud-asset-default.spec.ts for the same
// pattern.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])

View File

@@ -8,13 +8,14 @@ import type {
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createJobsWithExecutionTimes } from '@e2e/fixtures/helpers/AssetsHelper'
// The assets sidebar's sort options live inside the settings popover and are
// only rendered in cloud mode (`MediaAssetFilterBar.vue`:
// `:show-sort-options="isCloud"`). We tag tests `@cloud` so they run against
// the cloud Playwright project, and register `/api/assets`, `/api/jobs`, and
// `/internal/files/input` route handlers as auto fixtures — Playwright runs
// auto fixtures before the `comfyPage` fixture's internal `setup()`, so the
// page first-loads with mocks already in place.
// Post-FE-732 the sort options inside the settings popover render
// unconditionally on both Cloud and OSS builds. These tests keep the
// `@cloud` tag because the `/api/jobs` dependency (used by the generation-
// time sort) is still cloud-only; once OSS exposes equivalent jobs data we
// can drop the tag. Auto fixtures register `/api/assets`, `/api/jobs`, and
// `/internal/files/input` route handlers — Playwright runs auto fixtures
// before the `comfyPage` fixture's internal `setup()`, so the page first-
// loads with mocks already in place.
// Three jobs whose `(create_time, duration)` axes are intentionally
// misaligned so newest/oldest and longest/fastest sorts produce *different*

View File

@@ -1,5 +1,9 @@
import { expect } from '@playwright/test'
import {
STABLE_INPUT_IMAGE,
STABLE_INPUT_IMAGE_2
} from '@e2e/fixtures/data/assetFixtures'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
createMockJob,
@@ -708,6 +712,30 @@ test.describe('Assets sidebar - bulk actions', () => {
await expect(tab.deleteSelectedButton).toBeVisible()
})
test('Footer shows delete button when input assets selected (FE-732)', async ({
comfyPage
}) => {
// Pre-FE-732 the input-tab Delete footer button was gated by isCloud and
// hidden on OSS builds. Post-FE-732 it must render in both modes. This
// test runs on the default chromium project — i.e. the OSS build — and
// asserts the gate is gone.
await comfyPage.assets.mockCloudAssets({
assets: [STABLE_INPUT_IMAGE, STABLE_INPUT_IMAGE_2],
total: 2,
has_more: false
})
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
await tab.assetCards.first().click()
await expect(tab.deleteSelectedButton).toBeVisible()
})
test('Selection count displays correct number', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()

View File

@@ -1,236 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MOCK_FOLDERS: Record<string, string[]> = {
checkpoints: [
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVision_v51.safetensors'
],
loras: ['detail_tweaker_xl.safetensors', 'add_brightness.safetensors'],
vae: ['sdxl_vae.safetensors']
}
// ==========================================================================
// 1. Tab open/close
// ==========================================================================
test.describe('Model library sidebar - tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Opens model library tab and shows tree', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.modelTree).toBeVisible()
await expect(tab.searchInput).toBeVisible()
})
test('Shows refresh and load all folders buttons', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.refreshButton).toBeVisible()
await expect(tab.loadAllFoldersButton).toBeVisible()
})
})
// ==========================================================================
// 2. Folder display
// ==========================================================================
test.describe('Model library sidebar - folders', () => {
// Mocks are set up before setup(), so app.ts's loadModelFolders()
// call during initialization hits the mock and populates the store.
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Displays model folders after opening tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
await expect(tab.getFolderByLabel('loras')).toBeVisible()
await expect(tab.getFolderByLabel('vae')).toBeVisible()
})
test('Expanding a folder loads and shows models', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
// Click the folder to expand it
await tab.getFolderByLabel('checkpoints').click()
// Models should appear as leaf nodes
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
})
test('Expanding a different folder shows its models', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('loras').click()
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible()
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
})
})
// ==========================================================================
// 3. Search
// ==========================================================================
test.describe('Model library sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Search filters models by filename', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.searchInput.fill('dreamshaper')
// Wait for debounce (300ms) + load + render
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
// Other models should not be visible
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeHidden()
})
test('Clearing search restores folder view', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.searchInput.fill('dreamshaper')
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
// Clear the search
await tab.searchInput.fill('')
// Folders should be visible again (collapsed)
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
await expect(tab.getFolderByLabel('loras')).toBeVisible()
})
test('Search with no matches shows empty tree', async ({ comfyPage }) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
// Expand a folder and verify models are present before searching
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.leafNodes).not.toHaveCount(0)
await tab.searchInput.fill('nonexistent_model_xyz')
// Wait for debounce, then verify no leaf nodes
await expect.poll(() => tab.leafNodes.count()).toBe(0)
})
})
// ==========================================================================
// 4. Refresh and load all
// ==========================================================================
test.describe('Model library sidebar - refresh', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Refresh button reloads folder list', async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles({
checkpoints: ['model_a.safetensors']
})
await comfyPage.setup()
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
// Update mock to include a new folder
await comfyPage.modelLibrary.clearMocks()
await comfyPage.modelLibrary.mockFoldersWithFiles({
checkpoints: ['model_a.safetensors'],
loras: ['lora_b.safetensors']
})
// Wait for the refresh request to complete
const refreshRequest = comfyPage.page.waitForRequest(
(req) => req.url().endsWith('/experiment/models'),
{ timeout: 5000 }
)
await tab.refreshButton.click()
await refreshRequest
await expect(tab.getFolderByLabel('loras')).toBeVisible()
})
test('Load all folders button triggers loading all model data', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
// Wait for a per-folder model files request triggered by load all
const folderRequest = comfyPage.page.waitForRequest(
(req) =>
/\/api\/experiment\/models\/[^/]+$/.test(req.url()) &&
req.method() === 'GET',
{ timeout: 5000 }
)
await tab.loadAllFoldersButton.click()
await folderRequest
})
})
// ==========================================================================
// 5. Empty state
// ==========================================================================
test.describe('Model library sidebar - empty state', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Shows empty tree when no model folders exist', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockFoldersWithFiles({})
await comfyPage.setup()
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await expect(tab.modelTree).toBeVisible()
await expect(tab.folderNodes).toHaveCount(0)
await expect(tab.leafNodes).toHaveCount(0)
})
})

View File

@@ -141,7 +141,6 @@
<template v-if="isCompact">
<!-- Compact mode: Icon only -->
<Button
v-if="shouldShowDeleteButton"
size="icon"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
@@ -159,7 +158,6 @@
<template v-else>
<!-- Normal mode: Icon + Text -->
<Button
v-if="shouldShowDeleteButton"
variant="secondary"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
@@ -190,7 +188,6 @@
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@zoom="handleZoomClick(contextMenuAsset)"
@@ -249,7 +246,6 @@ import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import {
@@ -281,13 +277,6 @@ const isListView = computed(() => viewMode.value === 'list')
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
if (activeTab.value === 'input' && !isCloud) return false
return true
})
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)

View File

@@ -989,12 +989,8 @@ describe('scan skips interior of bypassed subgraph containers', () => {
expect.any(Function),
expect.any(Function)
)
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
expect(mediaScanSpy).not.toHaveBeenCalledWith(
rootGraph,
innerSubgraphNode,
false
)
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode)
expect(mediaScanSpy).not.toHaveBeenCalledWith(rootGraph, innerSubgraphNode)
})
})

View File

@@ -246,7 +246,7 @@ function scanSingleNodeMedia(node: LGraphNode): void {
if (!app.rootGraph) return
if (!getActiveExecutionId(node)) return
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
@@ -302,7 +302,7 @@ async function verifyAndAddPendingMedia(
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyMediaCandidates(pending, { isCloud })
await verifyMediaCandidates(pending, { allowCompactSuffix: isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)

View File

@@ -1,3 +1,5 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -6,7 +8,6 @@ import type {
EditorOutputLayer,
ImageRef
} from '@/stores/maskEditorDataStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
@@ -209,18 +210,11 @@ export function useMaskEditorSaver() {
}
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
const actualPaintedRef = await uploadImage(
outputData.paintedImage,
sourceRef
)
const actualPaintedMaskedRef = await uploadMask(
outputData.paintedMaskedImage,
actualPaintedRef
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
const actualPaintRef = await uploadLayer(outputData.paintLayer)
const actualPaintedRef = await uploadLayer(outputData.paintedImage)
const actualPaintedMaskedRef = await uploadLayer(
outputData.paintedMaskedImage
)
outputData.maskedImage.ref = actualMaskedRef
@@ -229,50 +223,10 @@ export function useMaskEditorSaver() {
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
}
async function uploadMask(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
async function uploadLayer(layer: EditorOutputLayer): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function uploadImage(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/image', {
method: 'POST',
@@ -280,23 +234,31 @@ export function useMaskEditorSaver() {
})
if (!response.ok) {
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
throw new Error(`Failed to upload: ${layer.ref.filename}`)
}
let data: UploadImageResponse
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
data = await response.json()
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
throw new Error(
`Invalid upload response for ${layer.ref.filename}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
return layer.ref
if (!data?.name) {
throw new Error(
`Upload response missing 'name' for ${layer.ref.filename}`
)
}
return {
filename: data.name,
subfolder: data.subfolder || '',
type: data.type || 'input'
}
}
async function updateNodePreview(
@@ -322,19 +284,8 @@ export function useMaskEditorSaver() {
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
// Widget value format differs between Cloud and OSS:
// - Cloud: JUST the filename (subfolder handled by backend)
// - OSS: subfolder/filename (traditional format)
let widgetValue: string
if (isCloud) {
widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
} else {
widgetValue =
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
mainRef.filename +
(mainRef.type ? ` [${mainRef.type}]` : '')
}
const widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
imageWidget.value = widgetValue

View File

@@ -384,7 +384,63 @@ describe('usePainter', () => {
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
expect(result).toBe('painter/uploaded.png [temp]')
expect(result).toBe('uploaded.png [input]')
const [, init] = fetchApiMock.mock.calls[0]
const body = init?.body as FormData
expect(body).toBeInstanceOf(FormData)
expect(body.get('type')).toBe('input')
expect(body.get('subfolder')).toBeNull()
})
it('throws when the upload response is missing a name', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => ({})
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter('test-node', '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/painter\.uploadError/)
})
it('throws when the upload response body is not valid JSON', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
vi.mocked(api.fetchApi).mockResolvedValueOnce({
status: 200,
json: async () => {
throw new SyntaxError('Unexpected token')
}
} as unknown as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter('test-node', '')
canvasEl.value = fakeCanvas
await nextTick()
await expect(
maskWidget.serializeValue!({} as LGraphNode, 0)
).rejects.toThrow(/painter\.uploadError/)
})
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {

View File

@@ -1,3 +1,4 @@
import type { UploadImageResponse } from '@comfyorg/ingest-types'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
@@ -12,7 +13,6 @@ import { hexToRgb } from '@/utils/colorUtil'
import type { Point } from '@/extensions/core/maskeditor/types'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -631,8 +631,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const name = `painter-${nodeId}-${Date.now()}.png`
const body = new FormData()
body.append('image', blob, name)
if (!isCloud) body.append('subfolder', 'painter')
body.append('type', isCloud ? 'input' : 'temp')
body.append('type', 'input')
let resp: Response
try {
@@ -658,7 +657,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
throw new Error(err)
}
let data: { name: string }
let data: UploadImageResponse
try {
data = await resp.json()
} catch (e) {
@@ -670,9 +669,16 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
throw new Error(err)
}
const result = isCloud
? `${data.name} [input]`
: `painter/${data.name} [temp]`
if (!data?.name) {
const err = t('painter.uploadError', {
status: resp.status,
statusText: "missing 'name' in response"
})
toastStore.addAlert(err)
throw new Error(err)
}
const result = `${data.name} [input]`
modelValue.value = result
isDirty.value = false
return result

View File

@@ -1283,23 +1283,9 @@ export function useCoreCommands(): ComfyCommand[] {
{
id: 'Comfy.BrowseModelAssets',
icon: 'pi pi-folder-open',
label: 'Experimental: Browse Model Assets',
label: 'Browse Model Assets',
versionAdded: '1.28.3',
function: async () => {
if (!useSettingStore().get('Comfy.Assets.UseAssetAPI')) {
const confirmed = await dialogService.confirm({
title: 'Enable Asset API',
message:
'The Asset API is currently disabled. Would you like to enable it?',
type: 'default'
})
if (!confirmed) return
const settingStore = useSettingStore()
await settingStore.set('Comfy.Assets.UseAssetAPI', true)
await workflowService.reloadCurrentWorkflow()
}
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models',
@@ -1318,22 +1304,6 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
},
{
id: 'Comfy.ToggleAssetAPI',
icon: 'pi pi-database',
label: () =>
`Experimental: ${
useSettingStore().get('Comfy.Assets.UseAssetAPI')
? 'Disable'
: 'Enable'
} AssetAPI`,
function: async () => {
const settingStore = useSettingStore()
const current = settingStore.get('Comfy.Assets.UseAssetAPI') ?? false
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
}
},
{
id: 'Comfy.ToggleQPOV2',
icon: 'pi pi-list',

View File

@@ -43,6 +43,12 @@ function resolveFlag<T>(
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Reactive feature-flag shape returned by {@link useFeatureFlags}.
* Exported so test mocks can stay in sync with the real shape.
*/
export type FeatureFlags = ReturnType<typeof useFeatureFlags>['flags']
/**
* Composable for reactive access to server-side feature flags
*/

View File

@@ -75,7 +75,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
}))
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
isAssetPreviewSupported: vi.fn(() => false),
persistThumbnail: vi.fn().mockResolvedValue(undefined)
}))
@@ -1483,22 +1482,9 @@ describe('useLoad3d', () => {
expect(composable).toBeDefined()
})
it('does not call captureThumbnail when asset preview is unsupported', async () => {
const { isAssetPreviewSupported } =
it('captures thumbnail and persists it when a model_file widget has a value', async () => {
const { persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(false)
const { handler } = await getModelReadyHandler()
handler()
await Promise.resolve()
expect(mockLoad3d.captureThumbnail).not.toHaveBeenCalled()
})
it('captures thumbnail and persists it when asset preview is supported and a model_file widget has a value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'cube.glb'
@@ -1523,9 +1509,8 @@ describe('useLoad3d', () => {
})
it('skips persistence when the model widget has no value', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
const { persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
mockNode.widgets = [
{ name: 'model_file', value: '' } as unknown as IWidget
]
@@ -1539,9 +1524,8 @@ describe('useLoad3d', () => {
})
it('swallows captureThumbnail rejections silently', async () => {
const { isAssetPreviewSupported, persistThumbnail } =
const { persistThumbnail } =
await import('@/platform/assets/utils/assetPreviewUtil')
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'',
'broken.glb'

View File

@@ -8,10 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import type {
AnimationItem,
CameraConfig,
@@ -862,7 +859,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isFirstModelLoad = false
},
modelReady: () => {
if (!load3d || !isAssetPreviewSupported()) return
if (!load3d) return
const node = nodeRef.value
const modelWidget = node?.widgets?.find(

View File

@@ -50,7 +50,6 @@ vi.mock('@/scripts/domWidget', () => ({
}))
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
isAssetPreviewSupported: vi.fn(() => false),
persistThumbnail: vi.fn()
}))

View File

@@ -17,10 +17,7 @@ type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
@@ -60,15 +57,13 @@ function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
silentOnNotFound: true
})
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''
void load3d
.whenLoadIdle()
.then(() => load3d.captureThumbnail(256, 256))
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
const filename = fileInfo.filename ?? ''
void load3d
.whenLoadIdle()
.then(() => load3d.captureThumbnail(256, 256))
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
})
}
@@ -194,16 +189,14 @@ useExtensionService().registerExtension({
silentOnNotFound: true
})
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''
const filename = fileInfo.filename ?? ''
void load3d
.whenLoadIdle()
.then(() => load3d.captureThumbnail(256, 256))
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
void load3d
.whenLoadIdle()
.then(() => load3d.captureThumbnail(256, 256))
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
})
}

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "حذف هذا الأصل؟",
"deleteSelectedDescription": "سيتم إزالة {count} أصل(أصول) بشكل دائم.",
"deleteSelectedTitle": "حذف الأصول المحددة؟",
"deletingImportedFilesCloudOnly": "حذف الملفات المستوردة مدعوم فقط في النسخة السحابية",
"failedToCreateNode": "فشل في إنشاء العقدة",
"failedToDeleteAsset": "فشل في حذف الأصل",
"failedToExportWorkflow": "فشل في تصدير سير العمل",

View File

@@ -3111,7 +3111,6 @@
"deleteSelectedTitle": "Delete selected assets?",
"deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
"assetDeletedSuccessfully": "Asset deleted successfully",
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
"failedToDeleteAsset": "Failed to delete asset",
"actions": {
"inspect": "Inspect asset",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "¿Eliminar este recurso?",
"deleteSelectedDescription": "{count} recurso(s) será(n) eliminado(s) permanentemente.",
"deleteSelectedTitle": "¿Eliminar los recursos seleccionados?",
"deletingImportedFilesCloudOnly": "La eliminación de archivos importados solo es compatible en la versión cloud",
"failedToCreateNode": "No se pudo crear el nodo",
"failedToDeleteAsset": "Error al eliminar el recurso",
"failedToExportWorkflow": "No se pudo exportar el flujo de trabajo",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "حذف این دارایی؟",
"deleteSelectedDescription": "{count} دارایی به طور دائمی حذف خواهد شد.",
"deleteSelectedTitle": "حذف دارایی‌های انتخاب‌شده؟",
"deletingImportedFilesCloudOnly": "حذف فایل‌های واردشده فقط در نسخه ابری پشتیبانی می‌شود",
"failedToCreateNode": "ایجاد node ناموفق بود",
"failedToDeleteAsset": "حذف دارایی ناموفق بود",
"failedToExportWorkflow": "خروجی گرفتن از workflow ناموفق بود",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "Supprimer cet élément ?",
"deleteSelectedDescription": "{count} élément(s) sera(ont) définitivement supprimé(s).",
"deleteSelectedTitle": "Supprimer les éléments sélectionnés ?",
"deletingImportedFilesCloudOnly": "La suppression des fichiers importés n'est prise en charge que dans la version cloud",
"failedToCreateNode": "Échec de la création du nœud",
"failedToDeleteAsset": "Échec de la suppression de l'élément",
"failedToExportWorkflow": "Échec de lexportation du flux de travail",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "このアセットを削除しますか?",
"deleteSelectedDescription": "{count} 個のアセットが完全に削除されます。",
"deleteSelectedTitle": "選択したアセットを削除しますか?",
"deletingImportedFilesCloudOnly": "インポートしたファイルの削除はクラウド版でのみサポートされています",
"failedToCreateNode": "ノードの作成に失敗しました",
"failedToDeleteAsset": "アセットの削除に失敗しました",
"failedToExportWorkflow": "ワークフローのエクスポートに失敗しました",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "이 에셋을 삭제하시겠습니까?",
"deleteSelectedDescription": "{count}개의 에셋이 영구적으로 제거됩니다.",
"deleteSelectedTitle": "선택한 에셋을 삭제하시겠습니까?",
"deletingImportedFilesCloudOnly": "가져온 파일 삭제는 클라우드 버전에서만 지원됩니다",
"failedToCreateNode": "노드 생성에 실패했습니다",
"failedToDeleteAsset": "에셋 삭제 실패",
"failedToExportWorkflow": "워크플로우 내보내기에 실패했습니다",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "Excluir este recurso?",
"deleteSelectedDescription": "{count} recurso(s) será(ão) removido(s) permanentemente.",
"deleteSelectedTitle": "Excluir recursos selecionados?",
"deletingImportedFilesCloudOnly": "A exclusão de arquivos importados é suportada apenas na versão em nuvem",
"failedToCreateNode": "Falha ao criar nó",
"failedToDeleteAsset": "Falha ao excluir o recurso",
"failedToExportWorkflow": "Falha ao exportar fluxo de trabalho",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "Удалить этот ресурс?",
"deleteSelectedDescription": "{count} ресурс(ов) будет удален безвозвратно.",
"deleteSelectedTitle": "Удалить выбранные ресурсы?",
"deletingImportedFilesCloudOnly": "Удаление импортированных файлов поддерживается только в облачной версии",
"failedToCreateNode": "Не удалось создать узел",
"failedToDeleteAsset": "Не удалось удалить ресурс",
"failedToExportWorkflow": "Не удалось экспортировать рабочий процесс",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "Bu varlık silinsin mi?",
"deleteSelectedDescription": "{count} varlık kalıcı olarak kaldırılacak.",
"deleteSelectedTitle": "Seçilen varlıklar silinsin mi?",
"deletingImportedFilesCloudOnly": "İçe aktarılan dosyaların silinmesi yalnızca bulut sürümünde desteklenir",
"failedToCreateNode": "Düğüm oluşturulamadı",
"failedToDeleteAsset": "Varlık silinemedi",
"failedToExportWorkflow": "İş akışı dışa aktarılamadı",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "刪除此資源?",
"deleteSelectedDescription": "{count} 個資源將被永久移除。",
"deleteSelectedTitle": "刪除選取的資源?",
"deletingImportedFilesCloudOnly": "僅雲端版本支援刪除匯入的檔案",
"failedToCreateNode": "建立節點失敗",
"failedToDeleteAsset": "刪除資源失敗",
"failedToExportWorkflow": "匯出工作流程失敗",

View File

@@ -2002,7 +2002,6 @@
"deleteAssetTitle": "删除此资产?",
"deleteSelectedDescription": "{count} 项资产将被永久删除。",
"deleteSelectedTitle": "删除所选资产?",
"deletingImportedFilesCloudOnly": "删除导入文件仅支持云版本",
"failedToCreateNode": "创建节点失败",
"failedToDeleteAsset": "删除资产失败",
"failedToExportWorkflow": "工作流导出失败",

View File

@@ -128,7 +128,7 @@ const props = defineProps<{
showLeftPanel?: boolean
title?: string
/**
* Storybook/test seam: when provided, bypasses the cloud-only
* Storybook/test seam: when provided, bypasses the
* `assetsStore.getAssets(cacheKey)` fetch and renders this list directly.
* Production callers should leave this undefined and rely on the store.
*/

View File

@@ -5,15 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import Media3DTop from './Media3DTop.vue'
const {
mockUseIntersectionObserver,
mockFindServerPreviewUrl,
mockIsAssetPreviewSupported
} = vi.hoisted(() => ({
mockUseIntersectionObserver: vi.fn(),
mockFindServerPreviewUrl: vi.fn(),
mockIsAssetPreviewSupported: vi.fn(() => true)
}))
const { mockUseIntersectionObserver, mockFindServerPreviewUrl } = vi.hoisted(
() => ({
mockUseIntersectionObserver: vi.fn(),
mockFindServerPreviewUrl: vi.fn()
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<typeof VueUseCore>()
@@ -24,8 +21,7 @@ vi.mock('@vueuse/core', async (importOriginal) => {
})
vi.mock('../utils/assetPreviewUtil', () => ({
findServerPreviewUrl: mockFindServerPreviewUrl,
isAssetPreviewSupported: mockIsAssetPreviewSupported
findServerPreviewUrl: mockFindServerPreviewUrl
}))
function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
@@ -66,7 +62,6 @@ const globalConfig = { mocks: { $t: (key: string) => key } }
describe('Media3DTop', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsAssetPreviewSupported.mockReturnValue(true)
})
it('renders the placeholder when no thumbnail has loaded', () => {
@@ -117,18 +112,6 @@ describe('Media3DTop', () => {
expect(img).toHaveAttribute('src', 'http://server/from-name.png')
})
it('skips the server query when isAssetPreviewSupported is false', async () => {
fireObserverIntersecting()
mockIsAssetPreviewSupported.mockReturnValue(false)
render(Media3DTop, {
props: { asset: makeAsset() },
global: globalConfig
})
await flush()
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('picks up a patched preview_url after the IntersectionObserver gate has closed', async () => {
// Initial render: observer fires, server has no preview yet — hasAttempted=true
fireObserverIntersecting()

View File

@@ -23,10 +23,7 @@ import { useIntersectionObserver } from '@vueuse/core'
import { onBeforeUnmount, ref, watch } from 'vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import {
findServerPreviewUrl,
isAssetPreviewSupported
} from '../utils/assetPreviewUtil'
import { findServerPreviewUrl } from '../utils/assetPreviewUtil'
const { asset } = defineProps<{ asset: AssetMeta }>()
@@ -49,7 +46,7 @@ async function loadThumbnail() {
if (!asset?.src) return
if (asset.name && isAssetPreviewSupported()) {
if (asset.name) {
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
if (serverPreviewUrl) {
thumbnailSrc.value = serverPreviewUrl

View File

@@ -144,7 +144,6 @@ import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -158,7 +157,10 @@ import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import {
getAssetDisplayName,
getAssetMetadataDimensions
} from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
@@ -279,12 +281,15 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const displayImageDimensions = computed(
() => getAssetMetadataDimensions(asset) ?? imageDimensions.value
)
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
if (fileKind.value === 'image' && displayImageDimensions.value) {
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)

View File

@@ -105,7 +105,13 @@ interface MediaAssetContextMenuExposed {
let capturedRef: MediaAssetContextMenuExposed | null = null
function mountComponent() {
interface MountOptions {
assetType?: 'output' | 'input'
showDeleteButton?: boolean
}
function mountComponent(opts: MountOptions = {}) {
const { assetType = 'output', showDeleteButton } = opts
const onHide = vi.fn()
const { container, unmount } = render(
defineComponent({
@@ -115,10 +121,10 @@ function mountComponent() {
onMounted(() => {
capturedRef = menuRef.value
})
return { menuRef, asset, onHide }
return { menuRef, asset, onHide, assetType, showDeleteButton }
},
template:
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
'<MediaAssetContextMenu ref="menuRef" :asset="asset" :asset-type="assetType" :show-delete-button="showDeleteButton" file-kind="image" @hide="onHide" />'
}),
{
global: {
@@ -198,4 +204,33 @@ describe('MediaAssetContextMenu', () => {
unmount()
})
it('shows Delete for input assets regardless of cloud mode (FE-732)', async () => {
const { container, unmount } = mountComponent({ assetType: 'input' })
await showMenu(container)
const deleteItem = capturedMenu.model.find(
(item) => item.label === 'mediaAsset.actions.delete'
)
expect(deleteItem).toBeDefined()
unmount()
})
it('hides Delete when parent explicitly passes showDeleteButton: false', async () => {
const { container, unmount } = mountComponent({
assetType: 'input',
showDeleteButton: false
})
await showMenu(container)
const deleteItem = capturedMenu.model.find(
(item) => item.label === 'mediaAsset.actions.delete'
)
expect(deleteItem).toBeUndefined()
unmount()
})
})

View File

@@ -37,7 +37,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
@@ -51,7 +50,7 @@ const {
asset,
assetType,
fileKind,
showDeleteButton,
showDeleteButton = true,
selectedAssets,
isBulkMode
} = defineProps<{
@@ -122,11 +121,8 @@ const showCopyJobId = computed(() => {
})
const shouldShowDeleteButton = computed(() => {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType === 'output' || (assetType === 'input' && isCloud)
return propAllows && typeAllows
const typeAllows = assetType === 'output' || assetType === 'input'
return showDeleteButton && typeAllows
})
// Context menu items

View File

@@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
const filterButtonStub = defineComponent({
name: 'MediaAssetFilterButton',
setup(_, { slots }) {
return () =>
h(
'div',
{ 'data-testid': 'filter-button' },
slots.default ? slots.default({ close: () => {} }) : []
)
}
})
const settingsButtonStub = defineComponent({
name: 'MediaAssetSettingsButton',
setup(_, { slots }) {
return () =>
h(
'div',
{ 'data-testid': 'settings-button' },
slots.default ? slots.default() : []
)
}
})
const settingsMenuCapture = { showSortOptions: undefined as unknown }
const settingsMenuStub = defineComponent({
name: 'MediaAssetSettingsMenu',
props: {
sortBy: { type: String, default: 'newest' },
viewMode: { type: String, default: 'grid' },
showSortOptions: { type: Boolean, default: false },
showGenerationTimeSort: { type: Boolean, default: false }
},
setup(props) {
settingsMenuCapture.showSortOptions = props.showSortOptions
return () => h('div', { 'data-testid': 'settings-menu' })
}
})
const sidebarTopAreaStub = defineComponent({
name: 'SidebarTopArea',
setup(_, { slots }) {
return () =>
h('div', { 'data-testid': 'sidebar-top-area' }, [
slots.default?.(),
slots.actions?.()
])
}
})
const searchInputStub = defineComponent({
name: 'SearchInput',
setup() {
return () => h('input', { 'data-testid': 'search-input' })
}
})
function mountFilterBar() {
return render(
defineComponent({
components: { MediaAssetFilterBar },
setup() {
const sortBy = ref<'newest' | 'oldest'>('newest')
const viewMode = ref<'grid' | 'list'>('grid')
return { sortBy, viewMode }
},
template:
'<MediaAssetFilterBar :search-query="\'\'" :media-type-filters="[]" v-model:sort-by="sortBy" v-model:view-mode="viewMode" />'
}),
{
global: {
stubs: {
MediaAssetFilterButton: filterButtonStub,
MediaAssetFilterMenu: { template: '<div />' },
MediaAssetSettingsButton: settingsButtonStub,
MediaAssetSettingsMenu: settingsMenuStub,
SidebarTopArea: sidebarTopAreaStub,
SearchInput: searchInputStub
},
mocks: {
$t: (key: string) => key
},
directives: {
tooltip: { mounted() {} }
}
}
}
)
}
describe('MediaAssetFilterBar', () => {
it('renders the filter button unconditionally (FE-732)', () => {
mountFilterBar()
expect(screen.getByTestId('filter-button')).toBeTruthy()
})
it('passes show-sort-options=true to the settings menu (FE-732)', () => {
settingsMenuCapture.showSortOptions = undefined
mountFilterBar()
expect(settingsMenuCapture.showSortOptions).toBe(true)
})
})

View File

@@ -9,7 +9,6 @@
/>
<template #actions>
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
>
<template #default="{ close }">
@@ -27,7 +26,7 @@
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
:show-sort-options="isCloud"
:show-sort-options="true"
:show-generation-time-sort
/>
</template>
@@ -39,7 +38,6 @@
<script setup lang="ts">
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { isCloud } from '@/platform/distribution/types'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'

View File

@@ -1,55 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { mapInputFileToAssetItem } from './assetMappers'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: vi.fn()
}))
describe('mapInputFileToAssetItem', () => {
it('preserves a clean filename', () => {
const asset = mapInputFileToAssetItem('photo.png', 0, 'input')
expect(asset.name).toBe('photo.png')
expect(asset.id).toBe('input-0-photo.png')
expect(asset.preview_url).toBe('/api/view?filename=photo.png&type=input')
})
it.for([
['photo.png [input]', 'photo.png'],
['photo.png [output]', 'photo.png'],
['photo.png [temp]', 'photo.png'],
['clip.mp4[input]', 'clip.mp4'],
['MyFile.WEBP [Input]', 'MyFile.WEBP']
])(
'strips ComfyUI directory annotation: %s -> %s',
([input, expectedName]) => {
const asset = mapInputFileToAssetItem(input, 1, 'input')
expect(asset.name).toBe(expectedName)
expect(asset.id).toBe(`input-1-${expectedName}`)
expect(asset.preview_url).toBe(
`/api/view?filename=${encodeURIComponent(expectedName)}&type=input`
)
}
)
it('leaves non-annotation brackets in the filename intact', () => {
const asset = mapInputFileToAssetItem('my [draft] image.png', 0, 'input')
expect(asset.name).toBe('my [draft] image.png')
})
it('uses the directory passed in for the type query param', () => {
const asset = mapInputFileToAssetItem('clip.mp4 [output]', 0, 'output')
expect(asset.preview_url).toBe('/api/view?filename=clip.mp4&type=output')
expect(asset.tags).toEqual(['output'])
})
})

View File

@@ -1,8 +1,6 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
/**
@@ -50,42 +48,3 @@ export function mapTaskOutputToAssetItem(
user_metadata: metadata
}
}
/**
* Strips ComfyUI's trailing directory-type annotation (e.g. ` [input]`,
* ` [output]`, `[temp]`) from a filename returned by the OSS internal
* `/internal/files/{type}` endpoint. The annotation is part of the wire
* format LoadImage-style widgets expect, but for the assets sidebar we
* want the canonical on-disk filename so type detection / titles work.
*/
function stripDirectoryAnnotation(filename: string): string {
return filename.replace(/\s*\[(?:input|output|temp)\]\s*$/i, '')
}
/**
* Maps input directory file to AssetItem format
* @param filename The filename
* @param index File index for unique ID
* @param directory The directory type
* @returns AssetItem formatted object
*/
export function mapInputFileToAssetItem(
filename: string,
index: number,
directory: 'input' | 'output' = 'input'
): AssetItem {
const cleanName = stripDirectoryAnnotation(filename)
const params = new URLSearchParams({ filename: cleanName, type: directory })
const preview_url = api.apiURL(`/view?${params}`)
appendCloudResParam(params, cleanName)
return {
id: `${directory}-${index}-${cleanName}`,
name: cleanName,
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
thumbnail_url: api.apiURL(`/view?${params}`),
preview_url
}
}

View File

@@ -898,6 +898,49 @@ describe('useMediaAssetActions', () => {
})
})
describe('deleteAssets - input asset cross-backend support (FE-732)', () => {
beforeEach(() => {
mockGetAssetType.mockReturnValue('input')
mockDeleteAsset.mockReset()
mockDeleteAsset.mockResolvedValue(undefined)
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
})
it('deletes input assets in OSS mode (isCloud = false)', async () => {
mockIsCloud.value = false
const actions = useMediaAssetActions()
const inputAsset = createMockAsset({
id: 'input-oss-1',
name: 'local-input.png',
tags: ['input']
})
await actions.deleteAssets(inputAsset)
expect(mockDeleteAsset).toHaveBeenCalledWith('input-oss-1')
})
it('deletes input assets in Cloud mode (isCloud = true)', async () => {
mockIsCloud.value = true
const actions = useMediaAssetActions()
const inputAsset = createMockAsset({
id: 'input-cloud-1',
name: 'cloud-input.png',
tags: ['input']
})
await actions.deleteAssets(inputAsset)
expect(mockDeleteAsset).toHaveBeenCalledWith('input-cloud-1')
})
})
describe('deleteAssets - confirmation dialog item names', () => {
beforeEach(() => {
mockIsCloud.value = true

View File

@@ -17,7 +17,10 @@ import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import {
getAssetDisplayName,
getAssetStoredFilename
} from '../utils/assetMetadataUtils'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
@@ -92,10 +95,6 @@ export function useMediaAssetActions() {
}
await api.deleteItem('history', jobId)
} else {
// Input assets can only be deleted in cloud environment
if (!isCloud) {
throw new Error(t('mediaAsset.deletingImportedFilesCloudOnly'))
}
await assetService.deleteAsset(asset.id)
}
}
@@ -296,12 +295,7 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && targetAsset.asset_hash
? targetAsset.asset_hash
: targetAsset.name
const filename = getAssetStoredFilename(targetAsset)
// Create annotated path for the asset
const annotated = createAnnotatedPath(
@@ -440,10 +434,7 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
const filename = getAssetStoredFilename(asset)
const annotated = createAnnotatedPath(
{

View File

@@ -6,6 +6,13 @@ const zAsset = z.object({
id: z.string(),
name: z.string(),
asset_hash: z.string().nullish(),
// BE-933 / BE-934 (RFC: BE-808 Asset Identity Semantics v2): namespace-rooted
// locator/display string, e.g. `input/sub/image.png` or
// `models/checkpoints/flux.safetensors`. Emitted on a BEST EFFORT basis
// (MAY, nullable). Identity is `id`, not `file_path`. Consumers MUST NOT
// assume `file_path` is populated and MUST degrade gracefully — see
// missingMediaAssetResolver.getAssetDetectionNames.
file_path: z.string().nullish(),
size: z.number().optional(), // TBD: Will be provided by history API in the future
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),

View File

@@ -10,21 +10,6 @@ import {
} from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistributionState.isCloud
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
vi.mock('@/stores/modelToNodeStore', () => {
const registeredNodeTypes: Record<string, string> = {
CheckpointLoaderSimple: 'ckpt_name',
@@ -85,59 +70,27 @@ function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
describe(assetService.shouldUseAssetBrowser, () => {
beforeEach(() => {
vi.clearAllMocks()
mockDistributionState.isCloud = false
mockSettingStoreGet.mockReturnValue(false)
})
it('returns false when not on cloud', () => {
mockDistributionState.isCloud = false
mockSettingStoreGet.mockReturnValue(true)
expect(
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
).toBe(false)
})
it('returns false when asset API setting is disabled', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(false)
expect(
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
).toBe(false)
})
it('returns false when node type is not eligible', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
expect(
assetService.shouldUseAssetBrowser('UnknownNode', 'some_input')
).toBe(false)
})
it('returns true when cloud, setting enabled, and node is eligible', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
it('returns true when the node is eligible', () => {
expect(
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
).toBe(true)
})
it('returns false when nodeType is undefined', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
expect(assetService.shouldUseAssetBrowser(undefined, 'ckpt_name')).toBe(
false
)
})
it('returns false when widget name does not match registered input', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
expect(
assetService.shouldUseAssetBrowser(
'CheckpointLoaderSimple',

View File

@@ -20,8 +20,6 @@ import type {
ModelFolder,
TagsOperationResult
} from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -175,7 +173,6 @@ function getLocalizedErrorMessage(errorCode: string): string {
const ASSETS_ENDPOINT = '/assets'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
@@ -231,9 +228,7 @@ function validateAssetResponse(data: unknown): AssetResponse {
if (result.success) return result.data
const error = fromZodError(result.error)
throw new Error(
`${EXPERIMENTAL_WARNING}Invalid asset response against zod schema:\n${error}`
)
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
function validateUploadedAssetResponse(
@@ -311,7 +306,7 @@ function createAssetService() {
: await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
@@ -380,17 +375,8 @@ function createAssetService() {
)
}
/**
* Checks if the asset API is enabled (cloud environment + user setting).
*/
function isAssetAPIEnabled(): boolean {
if (!isCloud) return false
return !!useSettingStore().get('Comfy.Assets.UseAssetAPI')
}
/**
* Checks if the asset browser should be used for a given node input.
* Combines the cloud environment check, user setting, and eligibility check.
*
* @param nodeType - The ComfyUI node comfyClass
* @param widgetName - The name of the widget to check
@@ -400,7 +386,7 @@ function createAssetService() {
nodeType: string | undefined,
widgetName: string
): boolean {
return isAssetAPIEnabled() && isAssetBrowserEligible(nodeType, widgetName)
return isAssetBrowserEligible(nodeType, widgetName)
}
/**
@@ -450,7 +436,7 @@ function createAssetService() {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
if (!res.ok) {
throw new Error(
`${EXPERIMENTAL_WARNING}Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
@@ -461,9 +447,7 @@ function createAssetService() {
const error = result.error
? fromZodError(result.error)
: 'Unknown validation error'
throw new Error(
`${EXPERIMENTAL_WARNING}Invalid asset response against zod schema:\n${error}`
)
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
/**
@@ -941,7 +925,6 @@ function createAssetService() {
return {
getAssetModelFolders,
getAssetModels,
isAssetAPIEnabled,
isAssetBrowserEligible,
shouldUseAssetBrowser,
getAssetsForNodeType,

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
@@ -10,13 +10,25 @@ import {
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetMetadataDimensions,
getAssetModelType,
getAssetSourceUrl,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
isCloudRef: { value: true }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return isCloudRef.value
}
}))
describe('assetMetadataUtils', () => {
const mockAsset: AssetItem = {
id: 'test-id',
@@ -295,6 +307,28 @@ describe('assetMetadataUtils', () => {
})
})
describe('getAssetStoredFilename', () => {
afterEach(() => {
isCloudRef.value = true
})
it('returns asset_hash on cloud when present', () => {
isCloudRef.value = true
expect(getAssetStoredFilename(mockAsset)).toBe('hash123')
})
it('falls back to name on cloud when asset_hash is missing', () => {
isCloudRef.value = true
const asset = { ...mockAsset, asset_hash: undefined }
expect(getAssetStoredFilename(asset)).toBe('test-model')
})
it('returns name on OSS regardless of asset_hash', () => {
isCloudRef.value = false
expect(getAssetStoredFilename(mockAsset)).toBe('test-model')
})
})
describe('getAssetFilename', () => {
it('returns user_metadata.filename when present', () => {
const asset = {
@@ -383,4 +417,80 @@ describe('assetMetadataUtils', () => {
expect(getAssetCardTitle(asset)).toBe('pretty.png')
})
})
describe('getAssetMetadataDimensions', () => {
it('returns dimensions when width/height are positive integers', () => {
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
expect(getAssetMetadataDimensions(asset)).toEqual({
width: 1024,
height: 768
})
})
it.for([
{ name: 'NaN width', width: Number.NaN, height: 768 },
{
name: 'Infinity height',
width: 1024,
height: Number.POSITIVE_INFINITY
},
{ name: 'zero width', width: 0, height: 768 },
{ name: 'negative height', width: 1024, height: -1 },
{ name: 'fractional width', width: 1024.5, height: 768 },
{ name: 'string width', width: '1024', height: 768 },
{ name: 'missing width', width: undefined, height: 768 }
])('returns undefined for invalid shape: $name', ({ width, height }) => {
const asset = { ...mockAsset, metadata: { width, height } }
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
})
it('returns undefined when metadata is absent', () => {
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
})
it('returns undefined when asset itself is undefined', () => {
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
})
})
describe('unified asset response shape (BE-808 RFC)', () => {
// Cloud asset: `asset.name` is a content hash; `display_name` carries
// the user-facing label.
const cloudShape: AssetItem = {
...mockAsset,
id: 'cloud-asset-id',
name: 'blake3:abc1234567890def.png',
asset_hash: 'blake3:abc1234567890def.png',
display_name: 'sunset.png'
}
// OSS asset: `asset.name` is already the filename; `display_name` is
// nullable per BE-1045 spec — clients fall back to `asset.name`.
const ossShape: AssetItem = {
...mockAsset,
id: 'oss-asset-id',
name: 'sunset.png',
asset_hash: null,
display_name: undefined
}
it('renders the same label for the Cloud and OSS shapes via getAssetDisplayFilename', () => {
expect(getAssetDisplayFilename(cloudShape)).toBe('sunset.png')
expect(getAssetDisplayFilename(ossShape)).toBe('sunset.png')
})
it('renders the same label via getAssetCardTitle', () => {
expect(getAssetCardTitle(cloudShape)).toBe('sunset.png')
expect(getAssetCardTitle(ossShape)).toBe('sunset.png')
})
it('honours OSS-emitted display_name when present', () => {
const ossWithDisplayName: AssetItem = {
...ossShape,
name: 'sunset.png',
display_name: 'Curated Sunset'
}
expect(getAssetDisplayFilename(ossWithDisplayName)).toBe('Curated Sunset')
})
})
})

View File

@@ -1,4 +1,5 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { isCivitaiUrl } from '@/utils/formatUtil'
/**
@@ -172,13 +173,27 @@ export function getAssetFilename(asset: AssetItem): string {
}
/**
* Gets the human-readable filename to render in UI surfaces.
* Fallback chain: user_metadata.filename → metadata.filename →
* asset.display_name → asset.name.
* Resolves the *stored* filename for an asset — the filename used to
* construct asset paths (for /view URLs, widget values), not the
* user-facing display name.
*
* `display_name` is populated by queue output mappers in Cloud where
* `asset.name` is a content hash. Use this helper for labels/titles only;
* for serialized identifiers use {@link getAssetFilename}.
* Cloud stores assets with `asset_hash` as the filename (content-
* addressed); OSS uses `name` (filesystem-backed). After BE-933/934
* emit `file_path` on both backends and the cloud spec sync brings
* the field into generated types, this collapses to
* `asset.file_path ?? asset.name` (no isCloud branch).
*
* For display use {@link getAssetDisplayFilename}; for serialized
* identifiers use {@link getAssetFilename}.
*/
export function getAssetStoredFilename(asset: AssetItem): string {
return isCloud && asset.asset_hash ? asset.asset_hash : asset.name
}
/**
* Human-readable filename for UI labels.
* Fallback: user_metadata.filename → metadata.filename → display_name → asset.name.
* For serialized identifiers use {@link getAssetFilename}.
*/
export function getAssetDisplayFilename(asset: AssetItem): string {
return (
@@ -191,7 +206,7 @@ export function getAssetDisplayFilename(asset: AssetItem): string {
* Prefers a user-curated name (user_metadata.name / metadata.name) when it
* actually differs from asset.name, so a user-renamed model keeps its
* display name. Falls through to {@link getAssetDisplayFilename} when the
* curated name is absent or equal to asset.name (Cloud hash case).
* curated name is absent or equal to asset.name (hash-keyed asset case).
*/
export function getAssetCardTitle(asset: AssetItem): string {
const curatedName = getStringProperty(asset, 'name')
@@ -208,3 +223,30 @@ export function getAssetCardTitle(asset: AssetItem): string {
export function getAssetUrlFilename(asset: AssetItem): string {
return asset.asset_hash || asset.name
}
/**
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
* NaN, Infinity, 0, negatives, and fractional values.
*/
function isValidDimension(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0
}
/**
* Returns the original image dimensions from `asset.metadata.{width,height}`
* when both pass shape validation, otherwise `undefined`. Callers should fall
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
* on runtimes that serve the original file but reports preview size on
* runtimes that serve a downscaled preview.
*/
export function getAssetMetadataDimensions(
asset: AssetItem | undefined
): { width: number; height: number } | undefined {
const w = asset?.metadata?.width
const h = asset?.metadata?.height
if (isValidDimension(w) && isValidDimension(h)) {
return { width: w, height: h }
}
return undefined
}

View File

@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
findOutputAsset,
findServerPreviewUrl,
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
@@ -11,8 +10,6 @@ const mockFetchApi = vi.hoisted(() => vi.fn())
const mockApiURL = vi.hoisted(() =>
vi.fn((path: string) => `http://localhost:8188${path}`)
)
const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
const mockUpdateAsset = vi.hoisted(() => vi.fn())
const mockSetAssetPreview = vi.hoisted(() => vi.fn())
@@ -21,14 +18,12 @@ vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi,
apiURL: mockApiURL,
api_base: '',
getServerFeature: mockGetServerFeature
api_base: ''
}
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetAPIEnabled: mockIsAssetAPIEnabled,
uploadAssetFromBase64: mockUploadAssetFromBase64,
updateAsset: mockUpdateAsset
}
@@ -81,26 +76,6 @@ const localAssetWithPreview = {
preview_url: '/api/view?type=output&filename=preview.png'
}
describe('isAssetPreviewSupported', () => {
beforeEach(() => vi.clearAllMocks())
it('returns true when asset API is enabled (cloud)', () => {
mockIsAssetAPIEnabled.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns true when server assets feature is enabled (local)', () => {
mockGetServerFeature.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns false when neither is enabled', () => {
mockIsAssetAPIEnabled.mockReturnValue(false)
mockGetServerFeature.mockReturnValue(false)
expect(isAssetPreviewSupported()).toBe(false)
})
})
describe('findOutputAsset', () => {
beforeEach(() => vi.clearAllMocks())

View File

@@ -10,12 +10,6 @@ interface AssetRecord {
preview_id?: string | null
}
export function isAssetPreviewSupported(): boolean {
return (
assetService.isAssetAPIEnabled() || api.getServerFeature('assets', false)
)
}
async function fetchAssets(
params: Record<string, string>
): Promise<AssetRecord[]> {

View File

@@ -6,10 +6,6 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
scanNodeMediaCandidates: mockScanNodeMediaCandidates
@@ -94,8 +90,7 @@ describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
expect.anything(),
inputNode,
true
inputNode
)
})

View File

@@ -1,6 +1,5 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { isCloud } from '@/platform/distribution/types'
import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
@@ -38,7 +37,7 @@ export function markDeletedAssetsAsMissingMedia(
node.mode === LGraphEventMode.BYPASS
)
continue
for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
for (const candidate of scanNodeMediaCandidates(rootGraph, node)) {
if (!deletedValues.has(candidate.name)) continue
candidates.push({ ...candidate, isMissing: true })
}

View File

@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import {
getMediaDisplayName,
useMissingMediaInteractions
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
const mockInputAssetsByFilename = new Map<string, AssetItem>()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
inputAssetsByFilename: mockInputAssetsByFilename,
updateInputs: vi.fn()
})
}))
vi.mock('@/platform/missingMedia/missingMediaStore', () => ({
useMissingMediaStore: () => ({
expandState: {},
pendingSelection: {},
uploadState: {},
missingMediaCandidates: null,
removeMissingMediaByName: vi.fn()
})
}))
const mockGetNodeByExecutionId = vi.fn()
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: (...args: unknown[]) =>
mockGetNodeByExecutionId(...args)
}))
const mockResolveComboValues = vi.fn()
vi.mock('@/utils/litegraphUtil', () => ({
resolveComboValues: (widget: unknown) => mockResolveComboValues(widget),
addToComboValues: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { id: 'mock-graph' }
}
}))
vi.mock('@/scripts/api', () => ({
api: { fetchApi: vi.fn() }
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: vi.fn() })
}))
const baseAsset: AssetItem = {
id: 'asset-1',
name: '',
tags: ['input'],
size: 1024
}
describe('getMediaDisplayName', () => {
beforeEach(() => {
mockInputAssetsByFilename.clear()
})
it('returns the input string when no matching asset is in the store (OSS pass-through)', () => {
expect(getMediaDisplayName('sunset.png')).toBe('sunset.png')
})
it('returns display_name when the matched asset carries one (Cloud unified shape)', () => {
const hash = 'blake3:abc1234567890def.png'
mockInputAssetsByFilename.set(hash, {
...baseAsset,
name: hash,
asset_hash: hash,
display_name: 'sunset.png'
})
expect(getMediaDisplayName(hash)).toBe('sunset.png')
})
it('falls back to asset.name when display_name is absent (legacy Cloud asset)', () => {
const hash = 'blake3:def4567890abc1234.png'
mockInputAssetsByFilename.set(hash, {
...baseAsset,
name: 'beach.png',
asset_hash: hash
})
expect(getMediaDisplayName(hash)).toBe('beach.png')
})
it('prefers metadata.filename over display_name and asset.name (shared helper chain)', () => {
const hash = 'blake3:fff1111222.png'
mockInputAssetsByFilename.set(hash, {
...baseAsset,
name: hash,
asset_hash: hash,
display_name: 'from_display.png',
metadata: { filename: 'from_metadata.png' }
})
expect(getMediaDisplayName(hash)).toBe('from_metadata.png')
})
it('falls back to display_name when filename metadata is absent (Cloud hash-keyed asset)', () => {
const hash = 'blake3:aaa2222333.png'
mockInputAssetsByFilename.set(hash, {
...baseAsset,
name: hash,
asset_hash: hash,
display_name: 'pretty.png'
})
expect(getMediaDisplayName(hash)).toBe('pretty.png')
})
})
describe('getLibraryOptions (integration with getMediaDisplayName)', () => {
const makeCandidate = (
overrides: Partial<MissingMediaCandidate> = {}
): MissingMediaCandidate => ({
nodeId: 1,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'missing.png',
isMissing: true,
...overrides
})
const makeNode = (widgetType: string = 'combo') => ({
widgets: [
{
name: 'image',
type: widgetType,
value: '',
options: {}
}
]
})
beforeEach(() => {
mockInputAssetsByFilename.clear()
mockGetNodeByExecutionId.mockReset()
mockResolveComboValues.mockReset()
})
it('returns empty array when the combo widget cannot be resolved', () => {
mockGetNodeByExecutionId.mockReturnValue(null)
const { getLibraryOptions } = useMissingMediaInteractions()
expect(getLibraryOptions(makeCandidate())).toEqual([])
expect(mockResolveComboValues).not.toHaveBeenCalled()
})
it('maps Cloud hash combo values to display_name via the shared helper chain', () => {
const candidateName = 'blake3:missing.png'
const hashA = 'blake3:aaa.png'
const hashB = 'blake3:bbb.png'
mockInputAssetsByFilename.set(hashA, {
...baseAsset,
name: hashA,
asset_hash: hashA,
display_name: 'sunset.png'
})
mockInputAssetsByFilename.set(hashB, {
...baseAsset,
name: hashB,
asset_hash: hashB,
metadata: { filename: 'beach.png' }
})
mockGetNodeByExecutionId.mockReturnValue(makeNode())
mockResolveComboValues.mockReturnValue([hashA, hashB, candidateName])
const { getLibraryOptions } = useMissingMediaInteractions()
const options = getLibraryOptions(makeCandidate({ name: candidateName }))
expect(options).toEqual([
{ name: 'sunset.png', value: hashA },
{ name: 'beach.png', value: hashB }
])
})
it('passes OSS filename combo values through when no matching asset exists', () => {
mockGetNodeByExecutionId.mockReturnValue(makeNode())
mockResolveComboValues.mockReturnValue([
'kitten.png',
'puppy.png',
'missing.png'
])
const { getLibraryOptions } = useMissingMediaInteractions()
const options = getLibraryOptions(makeCandidate({ name: 'missing.png' }))
expect(options).toEqual([
{ name: 'kitten.png', value: 'kitten.png' },
{ name: 'puppy.png', value: 'puppy.png' }
])
})
it('filters out the candidate name from the alternatives list', () => {
mockGetNodeByExecutionId.mockReturnValue(makeNode())
mockResolveComboValues.mockReturnValue([
'other.png',
'missing.png',
'extra.png'
])
const { getLibraryOptions } = useMissingMediaInteractions()
const options = getLibraryOptions(makeCandidate({ name: 'missing.png' }))
expect(options.map((o) => o.value)).toEqual(['other.png', 'extra.png'])
})
})

View File

@@ -1,6 +1,7 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { getAssetDisplayFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetsStore } from '@/stores/assetsStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type {
@@ -8,7 +9,6 @@ import type {
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'
@@ -85,13 +85,13 @@ export function getNodeDisplayLabel(
}
/**
* Resolve display name for a media file.
* Cloud widgets store asset hashes as values; this resolves them to
* human-readable names via assetsStore.getInputName().
* Resolve a media widget value (hash or filename) to a display label via the
* shared {@link getAssetDisplayFilename} fallback chain. Returns the input
* unchanged when no asset matches.
*/
export function getMediaDisplayName(name: string): string {
if (!isCloud) return name
return useAssetsStore().getInputName(name)
const asset = useAssetsStore().inputAssetsByFilename.get(name)
return asset ? getAssetDisplayFilename(asset) : name
}
export function useMissingMediaInteractions() {

View File

@@ -20,6 +20,18 @@ const { mockFetchHistoryPage } = vi.hoisted(() => ({
mockFetchHistoryPage: vi.fn()
}))
// Mutable holder so each test can flip the runtime `isCloud` to drive the
// resolver's generated-assets oracle selection (Cloud /api/assets vs OSS
// job history). The named-import binding into the resolver re-reads the
// getter on each access (ESM live binding semantics).
const isCloudHolder = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return isCloudHolder.value
}
}))
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
'@/platform/assets/services/assetService'
@@ -102,17 +114,17 @@ function makeAssetPage(
describe('resolveMissingMediaAssetSources', () => {
beforeEach(() => {
vi.clearAllMocks()
isCloudHolder.value = false
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([]))
})
it('loads cloud input assets when requested', async () => {
it('loads input assets from the unified listing on both backends', async () => {
const inputAsset = makeAsset('photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset])
const result = await resolveMissingMediaAssetSources({
isCloud: true,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
allowCompactSuffix: true
@@ -127,11 +139,11 @@ describe('resolveMissingMediaAssetSources', () => {
})
it('loads cloud output assets by tag when generated candidates need verification', async () => {
isCloudHolder.value = true
const outputAsset = makeAsset('output.png')
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset]))
const result = await resolveMissingMediaAssetSources({
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set(['output.png']),
allowCompactSuffix: true
@@ -151,13 +163,13 @@ describe('resolveMissingMediaAssetSources', () => {
})
it('stops reading cloud output asset pages once all requested names are found', async () => {
isCloudHolder.value = true
const target = 'target-output.png'
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
)
const result = await resolveMissingMediaAssetSources({
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([target]),
allowCompactSuffix: true
@@ -167,43 +179,42 @@ describe('resolveMissingMediaAssetSources', () => {
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
})
it('aborts cloud output asset loading when input asset loading fails', async () => {
const inputError = new Error('input failed')
let rejectInputAssets!: (err: Error) => void
let resolveOutputAssets!: (page: ReturnType<typeof makeAssetPage>) => void
mockGetInputAssetsIncludingPublic.mockReturnValueOnce(
new Promise<AssetItem[]>((_, reject) => {
rejectInputAssets = reject
})
)
mockGetAssetsPageByTag.mockReturnValueOnce(
new Promise((resolve) => {
resolveOutputAssets = resolve
})
it('returns empty inputAssets and keeps generated fetch alive when input fails (soft degrade)', async () => {
isCloudHolder.value = true
const inputError = new Error('GET /api/assets 404')
mockGetInputAssetsIncludingPublic.mockRejectedValueOnce(inputError)
mockGetAssetsPageByTag.mockResolvedValueOnce(
makeAssetPage([makeAsset('survivor.png')])
)
const promise = resolveMissingMediaAssetSources({
isCloud: true,
const result = await resolveMissingMediaAssetSources({
includeGeneratedAssets: true,
generatedMatchNames: new Set(['target.png']),
generatedMatchNames: new Set(['survivor.png']),
allowCompactSuffix: true
})
await Promise.resolve()
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
rejectInputAssets(inputError)
await expect(promise).rejects.toBe(inputError)
resolveOutputAssets(makeAssetPage([makeAsset('other.png')]))
await Promise.resolve()
const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal
expect(outputSignal).toBeInstanceOf(AbortSignal)
expect(outputSignal.aborted).toBe(true)
// Input oracle failed: degrade to empty. Generated oracle is independent
// and must keep running so output candidates can still verify.
expect(result.inputAssets).toEqual([])
expect(result.generatedAssets).toEqual([makeAsset('survivor.png')])
expect(mockFetchHistoryPage).not.toHaveBeenCalled()
})
it('returns empty generatedAssets when history fetch fails but inputs succeed', async () => {
const inputAsset = makeAsset('local-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValueOnce([inputAsset])
mockFetchHistoryPage.mockRejectedValueOnce(new Error('500 history'))
const result = await resolveMissingMediaAssetSources({
includeGeneratedAssets: true,
generatedMatchNames: new Set(['rendered.png']),
allowCompactSuffix: true
})
expect(result.inputAssets).toEqual([inputAsset])
expect(result.generatedAssets).toEqual([])
})
it('stops reading generated history once all requested names are found', async () => {
const target = 'target.png'
mockFetchHistoryPage.mockResolvedValueOnce(
@@ -214,7 +225,6 @@ describe('resolveMissingMediaAssetSources', () => {
)
const result = await resolveMissingMediaAssetSources({
isCloud: false,
includeGeneratedAssets: true,
generatedMatchNames: new Set([target]),
allowCompactSuffix: true
@@ -245,7 +255,6 @@ describe('resolveMissingMediaAssetSources', () => {
)
await resolveMissingMediaAssetSources({
isCloud: false,
includeGeneratedAssets: true,
generatedMatchNames: new Set([target]),
allowCompactSuffix: true
@@ -271,7 +280,6 @@ describe('resolveMissingMediaAssetSources', () => {
)
const result = await resolveMissingMediaAssetSources({
isCloud: false,
includeGeneratedAssets: true,
generatedMatchNames: new Set(['missing.png']),
allowCompactSuffix: true
@@ -292,7 +300,6 @@ describe('resolveMissingMediaAssetSources', () => {
)
const result = await resolveMissingMediaAssetSources({
isCloud: false,
includeGeneratedAssets: true,
generatedMatchNames: new Set(['missing.png']),
allowCompactSuffix: true
@@ -301,8 +308,69 @@ describe('resolveMissingMediaAssetSources', () => {
expect(result.generatedAssets).toHaveLength(1)
expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2)
})
})
it('includes slash and backslash subfolder identifiers for detection', () => {
describe('getAssetDetectionNames', () => {
it('unions file_path with legacy keys so deprecation-window widget values keep matching', () => {
const names = getAssetDetectionNames(
{
id: 'a1',
name: 'legacy.png',
asset_hash: 'blake3:abc',
file_path: 'input/sub/photo.png',
mime_type: null,
tags: ['input'],
user_metadata: { subfolder: 'old-subfolder' }
},
{ allowCompactSuffix: true }
)
// A widget value in any of these legacy shapes (or the new file_path
// shape) must match — BE-808 RFC §4 says file_path is a locator, not the
// identity, and workflow widget values do not auto-upgrade.
expect(names).toEqual(
expect.arrayContaining([
'input/sub/photo.png',
'blake3:abc',
'legacy.png',
'old-subfolder/legacy.png'
])
)
})
it('falls back to the legacy union when file_path is null', () => {
const names = getAssetDetectionNames(
{
id: 'a1',
name: 'legacy.png',
asset_hash: 'blake3:abc',
file_path: null,
mime_type: null,
tags: ['input']
},
{ allowCompactSuffix: true }
)
expect(names).toEqual(expect.arrayContaining(['legacy.png', 'blake3:abc']))
})
it('returns an empty list when file_path, asset_hash, and name are all absent', () => {
const names = getAssetDetectionNames(
{
id: 'a1',
name: '',
asset_hash: null,
file_path: null,
mime_type: null,
tags: []
},
{ allowCompactSuffix: true }
)
expect(names).toEqual([])
})
it('includes slash and backslash subfolder identifiers when file_path is null', () => {
const names = getAssetDetectionNames(
{
...makeAsset('child\\photo.png', 'hash.png'),

View File

@@ -1,9 +1,11 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { api } from '@/scripts/api'
import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil'
import { isAbortError } from '@/utils/typeGuardUtil'
import { getMediaPathDetectionNames } from './mediaPathDetectionUtil'
const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200
@@ -20,7 +22,6 @@ export interface MissingMediaAssetSources {
export interface ResolveMissingMediaAssetSourcesOptions {
signal?: AbortSignal
isCloud: boolean
includeGeneratedAssets: boolean
generatedMatchNames: ReadonlySet<string>
allowCompactSuffix: boolean
@@ -32,7 +33,6 @@ export type MissingMediaAssetResolver = (
export async function resolveMissingMediaAssetSources({
signal,
isCloud,
includeGeneratedAssets,
generatedMatchNames,
allowCompactSuffix
@@ -48,43 +48,73 @@ export async function resolveMissingMediaAssetSources({
}
try {
const [inputAssets, generatedAssets] = await Promise.all([
abortSiblingsOnFailure(
isCloud
? assetService.getInputAssetsIncludingPublic(controller.signal)
: Promise.resolve<AssetItem[]>([]),
controller
),
abortSiblingsOnFailure(
includeGeneratedAssets
? fetchGeneratedAssets(controller.signal, {
isCloud,
generatedMatchNames,
pathOptions
})
: Promise.resolve<AssetItem[]>([]),
controller
)
// Input assets (`/api/assets`) and generated assets (Cloud asset API or
// OSS `/history`) are independent oracles. Use `allSettled` so a failure
// in one — e.g. `/api/assets` 404 on a pre-BE-786 OSS instance, or zod
// schema skew during a BE-934 partial deploy — doesn't take down the
// other path. Each branch soft-degrades to an empty list; the caller
// then marks affected candidates missing instead of swallowing the
// whole verification with a toast.
const [inputResult, generatedResult] = await Promise.allSettled([
assetService.getInputAssetsIncludingPublic(controller.signal),
includeGeneratedAssets
? fetchGeneratedAssets(controller.signal, {
generatedMatchNames,
pathOptions
})
: Promise.resolve<AssetItem[]>([])
])
return { inputAssets, generatedAssets }
return {
inputAssets: unwrapAssetFetchResult(inputResult, 'inputAssets'),
generatedAssets: unwrapAssetFetchResult(
generatedResult,
'generatedAssets'
)
}
} finally {
signal?.removeEventListener('abort', abortFromCaller)
}
}
function unwrapAssetFetchResult(
result: PromiseSettledResult<AssetItem[]>,
label: 'inputAssets' | 'generatedAssets'
): AssetItem[] {
if (result.status === 'fulfilled') return result.value
if (isAbortError(result.reason)) return []
console.warn(
`[missingMedia] ${label} fetch failed; degrading to empty list.`,
result.reason
)
return []
}
interface FetchGeneratedAssetsOptions {
isCloud: boolean
generatedMatchNames: ReadonlySet<string>
pathOptions: MediaPathDetectionOptions
}
/**
* Derive comparison keys for matching workflow widget values against an asset.
*
* Per RFC BE-808 v2 (Asset Identity Semantics), `id` is the identity field;
* `file_path` is a namespace-rooted locator/display string emitted on a
* BEST EFFORT basis by BE-933 / BE-934. Workflow widget values predate the
* `file_path` rollout and may still be bare filenames, hashes, or annotated
* paths, so detection keys union `file_path`, `asset_hash`, `name`, and
* `subfolder + name` variants — a widget value in any of those legacy
* shapes must keep matching once an asset starts emitting `file_path`.
* Both backends round-trip `name` through the BE-792 deprecation window,
* so the legacy keys stay valid.
*/
export function getAssetDetectionNames(
asset: AssetItem,
options: MediaPathDetectionOptions
): string[] {
const names = new Set<string>()
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
addPathDetectionNames(names, asset.file_path, options)
addPathDetectionNames(names, asset.asset_hash, options)
addPathDetectionNames(names, asset.name, options)
@@ -96,9 +126,16 @@ export function getAssetDetectionNames(
return Array.from(names)
}
/**
* Pick the generated-assets oracle by runtime. Cloud queries
* `/api/assets?include_tags=output`; Core synthesizes `AssetItem` shells
* from job-execution history because OSS does not auto-register output
* files as assets (pre-BE-786). Unifying this oracle is a separate
* concern — track as a follow-up to FE-746.
*/
async function fetchGeneratedAssets(
signal: AbortSignal | undefined,
{ isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
{ generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
): Promise<AssetItem[]> {
if (isCloud) {
return await fetchCloudGeneratedAssets(
@@ -212,18 +249,6 @@ async function fetchGeneratedHistoryAssets(
}
}
async function abortSiblingsOnFailure<T>(
promise: Promise<T>,
controller: AbortController
): Promise<T> {
try {
return await promise
} catch (err) {
if (!controller.signal.aborted) controller.abort(err)
throw err
}
}
function addPathDetectionNames(
names: Set<string>,
value: string | null | undefined,

View File

@@ -28,6 +28,17 @@ const { mockFetchHistoryPage } = vi.hoisted(() => ({
mockFetchHistoryPage: vi.fn()
}))
// Mutable runtime `isCloud` holder for tests that exercise the default
// resolver's generated-assets oracle (Cloud /api/assets vs OSS history).
// Tests with their own `resolveAssetSources` mock can ignore this.
const isCloudHolder = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return isCloudHolder.value
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
@@ -159,7 +170,7 @@ function makeHistoryJob(
}
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
it('returns a candidate for a LoadImage node and defers missingness to the verifier', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
@@ -168,17 +179,18 @@ describe('scanNodeMediaCandidates', () => {
0
)
const result = scanNodeMediaCandidates(graph, node, false)
const result = scanNodeMediaCandidates(graph, node)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: true
})
expect(result).toEqual([
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: undefined
}
])
})
it('returns empty for non-media node types', () => {
@@ -190,39 +202,30 @@ describe('scanNodeMediaCandidates', () => {
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
})
it('returns empty for node with no widgets', () => {
const graph = makeGraph([])
const node = makeMediaNode(1, 'LoadImage', [], 0)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
})
it.for([false, true])(
'returns empty while a media upload is pending on the node (isCloud: %s)',
(isCloud) => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadVideo',
[makeMediaCombo('file', 'clip.mp4', [])],
0
)
node.isUploading = true
it('returns empty while a media upload is pending on the node', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadVideo',
[makeMediaCombo('file', 'clip.mp4', [])],
0
)
node.isUploading = true
const result = scanNodeMediaCandidates(graph, node, isCloud)
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
})
expect(result).toEqual([])
}
)
it('detects missing media again after upload state clears', () => {
it('emits the candidate again after upload state clears', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
@@ -232,16 +235,16 @@ describe('scanNodeMediaCandidates', () => {
)
node.isUploading = true
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([])
expect(scanNodeMediaCandidates(graph, node)).toEqual([])
node.isUploading = false
expect(scanNodeMediaCandidates(graph, node, false)).toEqual([
expect(scanNodeMediaCandidates(graph, node)).toEqual([
expect.objectContaining({
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
name: 'clip.mp4',
isMissing: true
isMissing: undefined
})
])
})
@@ -250,126 +253,51 @@ describe('scanNodeMediaCandidates', () => {
{
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
value: 'photo.png [input]',
option: 'photo.png'
mediaType: 'image' as const,
value: 'photo.png [input]'
},
{
nodeType: 'LoadImageMask',
widgetName: 'image',
mediaType: 'image',
value: 'mask.png [input]',
option: 'mask.png'
mediaType: 'image' as const,
value: 'mask.png [input]'
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video',
value: 'clip.mp4 [input]',
option: 'clip.mp4'
mediaType: 'video' as const,
value: 'clip.mp4 [input]'
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
mediaType: 'audio',
value: 'sound.wav [input]',
option: 'sound.wav'
mediaType: 'audio' as const,
value: 'sound.wav [input]'
}
])(
'matches annotated $nodeType values against clean OSS options',
({ nodeType, widgetName, mediaType, value, option }) => {
'passes annotated $nodeType values through unchanged for async verification',
({ nodeType, widgetName, mediaType, value }) => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
nodeType,
[makeMediaCombo(widgetName, value, [option])],
[makeMediaCombo(widgetName, value, [])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
const result = scanNodeMediaCandidates(graph, node)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
nodeType,
widgetName,
mediaType,
name: value,
isMissing: false
})
expect(result).toEqual([
expect.objectContaining({
nodeType,
widgetName,
mediaType,
name: value,
isMissing: undefined
})
])
}
)
it.for([
{
nodeType: 'LoadImage',
widgetName: 'image',
value: 'photo.png [output]'
},
{
nodeType: 'LoadVideo',
widgetName: 'file',
value: 'clip.mp4 [output]'
},
{
nodeType: 'LoadAudio',
widgetName: 'audio',
value: 'sound.wav [output]'
}
])(
'leaves OSS $nodeType output annotations pending when not in options',
({ nodeType, widgetName, value }) => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
nodeType,
[makeMediaCombo(widgetName, value, ['other-file.png', value])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result[0]).toMatchObject({
nodeType,
widgetName,
name: value,
isMissing: undefined
})
}
)
it('marks OSS input annotations missing when the clean option is absent', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result[0]).toMatchObject({
name: 'photo.png [input]',
isMissing: true
})
})
it('does not treat compact Cloud annotations as valid OSS options', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result[0]).toMatchObject({
name: 'photo.png[input]',
isMissing: true
})
})
})
describe('scanAllMediaCandidates', () => {
@@ -380,8 +308,7 @@ describe('scanAllMediaCandidates', () => {
[makeMediaCombo('image', 'photo.png', ['other.png'])],
2 // NEVER
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
expect(scanAllMediaCandidates(makeGraph([node]))).toEqual([])
})
it('skips bypassed nodes (mode === BYPASS)', () => {
@@ -391,20 +318,25 @@ describe('scanAllMediaCandidates', () => {
[makeMediaCombo('image', 'photo.png', ['other.png'])],
4 // BYPASS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
expect(scanAllMediaCandidates(makeGraph([node]))).toEqual([])
})
it('includes active nodes (mode === ALWAYS)', () => {
it('includes active nodes (mode === ALWAYS) with isMissing deferred to the verifier', () => {
const node = makeMediaNode(
3,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
0 // ALWAYS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
const result = scanAllMediaCandidates(makeGraph([node]))
expect(result).toEqual([
expect.objectContaining({
nodeId: '3',
nodeType: 'LoadImage',
name: 'photo.png',
isMissing: undefined
})
])
})
})
@@ -494,6 +426,7 @@ describe('verifyMediaCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
isCloudHolder.value = false
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
mockFetchHistoryPage.mockResolvedValue({
@@ -516,7 +449,7 @@ describe('verifyMediaCandidates', () => {
])
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources
})
@@ -525,7 +458,6 @@ describe('verifyMediaCandidates', () => {
expect(candidates[2].isMissing).toBe(true)
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: true,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
allowCompactSuffix: true
@@ -542,7 +474,7 @@ describe('verifyMediaCandidates', () => {
])
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources
})
@@ -550,6 +482,58 @@ describe('verifyMediaCandidates', () => {
expect(candidates[1].isMissing).toBe(true)
})
it('matches widget values against file_path when the asset emits it (post BE-933 / BE-934)', async () => {
const candidates = [
makeCandidate('1', 'input/sub/photo.png', { isMissing: undefined }),
makeCandidate('2', 'input/sub/missing.png', { isMissing: undefined })
]
const assetWithFilePath: AssetItem = {
id: 'asset-1',
// Legacy `name` and `asset_hash` deliberately diverge from the
// widget value; `file_path` is the sole reason the match succeeds.
name: 'unrelated.png',
asset_hash: 'blake3:abc',
file_path: 'input/sub/photo.png',
mime_type: null,
tags: ['input']
}
const resolveAssetSources = makeAssetResolver([assetWithFilePath])
await verifyMediaCandidates(candidates, {
allowCompactSuffix: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(true)
})
it('matches a bare-filename widget value against a file_path-emitting asset (BE-808 deprecation window)', async () => {
// Pre-BE-933/934 workflow: widget value is the bare filename the user
// originally picked. Post-BE-933/934 asset: emits a namespace-rooted
// `file_path`. The two shapes must still match — workflow JSON does
// not auto-upgrade when the backend response shape changes.
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
const assetPostBE: AssetItem = {
id: 'asset-1',
name: 'photo.png',
asset_hash: null,
file_path: 'input/sub/photo.png',
mime_type: null,
tags: ['input']
}
const resolveAssetSources = makeAssetResolver([assetPostBE])
await verifyMediaCandidates(candidates, {
allowCompactSuffix: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(false)
})
it('matches annotated candidate names against clean asset names', async () => {
const candidates = [
makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
@@ -572,7 +556,7 @@ describe('verifyMediaCandidates', () => {
)
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources
})
@@ -610,13 +594,12 @@ describe('verifyMediaCandidates', () => {
)
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources
})
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: true,
includeGeneratedAssets: true,
generatedMatchNames: new Set([
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
@@ -636,7 +619,7 @@ describe('verifyMediaCandidates', () => {
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources
})
@@ -650,14 +633,14 @@ describe('verifyMediaCandidates', () => {
const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources
})
expect(candidates[0].isMissing).toBe(true)
})
it('verifies OSS output candidates against generated history without cloud assets', async () => {
it('verifies OSS output candidates against generated history alongside the unified input listing', async () => {
const candidates = [
makeCandidate('1', 'subfolder/photo.png [output]', {
isMissing: undefined
@@ -672,9 +655,11 @@ describe('verifyMediaCandidates', () => {
hasMore: false
})
await verifyMediaCandidates(candidates, { isCloud: false })
await verifyMediaCandidates(candidates, { allowCompactSuffix: false })
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
expect.any(AbortSignal)
)
expect(mockFetchHistoryPage).toHaveBeenCalledWith(
expect.any(Function),
200,
@@ -693,13 +678,12 @@ describe('verifyMediaCandidates', () => {
const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
await verifyMediaCandidates(candidates, {
isCloud: false,
allowCompactSuffix: false,
resolveAssetSources
})
expect(resolveAssetSources).toHaveBeenCalledWith({
signal: undefined,
isCloud: false,
includeGeneratedAssets: false,
generatedMatchNames: new Set(),
allowCompactSuffix: false
@@ -717,7 +701,7 @@ describe('verifyMediaCandidates', () => {
)
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources
})
@@ -730,7 +714,7 @@ describe('verifyMediaCandidates', () => {
]
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
resolveAssetSources: makeAssetResolver([])
})
@@ -745,7 +729,7 @@ describe('verifyMediaCandidates', () => {
makeAsset('stored-photo.png', existingHash)
])
await verifyMediaCandidates(candidates, { isCloud: true })
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
expect(candidates[0].isMissing).toBe(false)
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
@@ -755,6 +739,7 @@ describe('verifyMediaCandidates', () => {
})
it('reads cloud output assets by tag for output candidates', async () => {
isCloudHolder.value = true
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const candidates = [
@@ -764,7 +749,7 @@ describe('verifyMediaCandidates', () => {
makeAssetPage([makeAsset(outputHash)])
)
await verifyMediaCandidates(candidates, { isCloud: true })
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
expect.any(AbortSignal)
@@ -806,7 +791,7 @@ describe('verifyMediaCandidates', () => {
hasMore: false
})
await verifyMediaCandidates(candidates, { isCloud: false })
await verifyMediaCandidates(candidates, { allowCompactSuffix: false })
expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
1,
@@ -839,7 +824,7 @@ describe('verifyMediaCandidates', () => {
hasMore: false
})
await verifyMediaCandidates(candidates, { isCloud: false })
await verifyMediaCandidates(candidates, { allowCompactSuffix: false })
expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(true)
@@ -854,7 +839,7 @@ describe('verifyMediaCandidates', () => {
]
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
signal: controller.signal
})
@@ -876,7 +861,7 @@ describe('verifyMediaCandidates', () => {
})
await verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
signal: controller.signal,
resolveAssetSources
})
@@ -887,7 +872,7 @@ describe('verifyMediaCandidates', () => {
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
await verifyMediaCandidates(candidates, { isCloud: true })
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
expect(candidates[0].isMissing).toBe(true)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -896,7 +881,7 @@ describe('verifyMediaCandidates', () => {
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
await verifyMediaCandidates(candidates, { isCloud: true })
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
expect(candidates[0].isMissing).toBe(false)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -905,7 +890,7 @@ describe('verifyMediaCandidates', () => {
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
await verifyMediaCandidates(candidates, { isCloud: true })
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
@@ -920,7 +905,7 @@ describe('verifyMediaCandidates', () => {
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
await verifyMediaCandidates(candidates, { isCloud: true })
await verifyMediaCandidates(candidates, { allowCompactSuffix: true })
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
expect.any(AbortSignal)
@@ -942,7 +927,7 @@ describe('verifyMediaCandidates', () => {
await expect(
verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
signal: controller.signal,
resolveAssetSources
})
@@ -969,7 +954,7 @@ describe('verifyMediaCandidates', () => {
await expect(
verifyMediaCandidates(candidates, {
isCloud: true,
allowCompactSuffix: true,
signal: controller.signal
})
).resolves.toBeUndefined()

View File

@@ -17,7 +17,6 @@ import {
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isAbortError } from '@/utils/typeGuardUtil'
import {
@@ -49,13 +48,13 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
/**
* Scan combo widgets on media nodes for file values that may be missing.
*
* OSS: `isMissing` is resolved immediately via widget options unless an
* output annotation needs generated-history verification.
* Cloud: `isMissing` left `undefined` for async verification.
* Candidates leave `isMissing` as `undefined`; resolution happens
* asynchronously in `verifyMediaCandidates` against the unified asset
* listing. Both backends consult the same oracle (per RFC BE-808 v2 /
* BE-933 + BE-934).
*/
export function scanAllMediaCandidates(
rootGraph: LGraph,
isCloud: boolean
rootGraph: LGraph
): MissingMediaCandidate[] {
if (!rootGraph) return []
@@ -71,17 +70,16 @@ export function scanAllMediaCandidates(
)
continue
candidates.push(...scanNodeMediaCandidates(rootGraph, node, isCloud))
candidates.push(...scanNodeMediaCandidates(rootGraph, node))
}
return candidates
}
/** Scan a single node for missing media candidates (OSS immediate resolution). */
/** Scan a single node for missing media candidates (async resolution). */
export function scanNodeMediaCandidates(
rootGraph: LGraph,
node: LGraphNode,
isCloud: boolean
node: LGraphNode
): MissingMediaCandidate[] {
if (!node.widgets?.length) return []
@@ -100,30 +98,13 @@ export function scanNodeMediaCandidates(
const value = widget.value
if (typeof value !== 'string' || !value.trim()) continue
let isMissing: boolean | undefined
if (isCloud) {
isMissing = undefined
} else {
const type = getAnnotatedMediaPathTypeForDetection(value)
if (type === 'output') {
isMissing = undefined
} else {
const options = resolveComboValues(widget)
const detectionNames = getMediaPathDetectionNames(value)
const existsInOptions = detectionNames.some((name) =>
options.includes(name)
)
isMissing = !existsInOptions
}
}
candidates.push({
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
mediaType: mediaInfo.mediaType,
name: value,
isMissing
isMissing: undefined
})
}
@@ -131,27 +112,32 @@ export function scanNodeMediaCandidates(
}
interface MediaVerificationOptions {
isCloud: boolean
/**
* Whether to accept compact `file.png[input]` suffix annotations in
* addition to the canonical spaced `file.png [input]` form. Cloud emits
* compact annotations on legacy widget values. Tracked as N1 in the
* RFC; retained until widget values stop being filenames.
*/
allowCompactSuffix: boolean
signal?: AbortSignal
resolveAssetSources?: MissingMediaAssetResolver
}
/**
* Verify media candidates against assets available to the current runtime.
* Verify media candidates against the unified asset listing.
*
* A candidate's `name` may be either a filename or an opaque asset hash.
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
* match against the union of `asset.name` and `asset.asset_hash`. Output
* candidates are matched against Cloud output assets or Core generated-history
* assets because Core resolves those annotations against output folders, not
* input files.
* Cloud accepts compact annotated media paths, so only Cloud verification
* normalizes compact suffixes.
* A candidate's `name` is the widget-value string (filename or annotated
* path). It is matched against each asset's `file_path` (canonical key
* per RFC BE-808 v2) and, for assets where `file_path` is null
* (hash-only Core registrations, tagless Cloud rows, legacy data), the
* legacy union of `asset_hash` / `name` / `subfolder + name`. Output
* candidates match against generated assets; everything else against
* input assets.
*/
export async function verifyMediaCandidates(
candidates: MissingMediaCandidate[],
{
isCloud,
allowCompactSuffix,
signal,
resolveAssetSources = resolveMissingMediaAssetSources
}: MediaVerificationOptions
@@ -161,9 +147,7 @@ export async function verifyMediaCandidates(
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
// Core stores spaced annotations such as `file.png [output]`; Cloud also
// accepts compact forms such as `file.png[output]`.
const pathOptions = { allowCompactSuffix: isCloud }
const pathOptions = { allowCompactSuffix }
const generatedMatchNames = getGeneratedCandidateMatchNames(
pending,
pathOptions
@@ -174,10 +158,9 @@ export async function verifyMediaCandidates(
try {
const assetSources = await resolveAssetSources({
signal,
isCloud,
includeGeneratedAssets: generatedMatchNames.size > 0,
generatedMatchNames,
allowCompactSuffix: isCloud
allowCompactSuffix
})
inputAssets = assetSources.inputAssets
generatedAssets = assetSources.generatedAssets

View File

@@ -1218,14 +1218,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.30.3'
},
{
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',
type: 'hidden',
tooltip: 'Use new Asset API for model browsing',
defaultValue: isCloud ? true : false,
experimental: true
},
{
id: 'Comfy.VersionCompatibility.DisableWarnings',
name: 'Disable version compatibility warnings',

View File

@@ -18,8 +18,7 @@ const flushPromises = () =>
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
shouldUseAssetBrowser: vi.fn(() => true),
isAssetAPIEnabled: vi.fn(() => true)
shouldUseAssetBrowser: vi.fn(() => true)
}
}))

View File

@@ -17,12 +17,10 @@ const i18n = createI18n({
// Mock state for asset service
const mockShouldUseAssetBrowser = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
shouldUseAssetBrowser: mockShouldUseAssetBrowser,
isAssetAPIEnabled: mockIsAssetAPIEnabled
shouldUseAssetBrowser: mockShouldUseAssetBrowser
}
}))
@@ -68,7 +66,6 @@ const globalConfig = {
describe('WidgetSelect Value Binding', () => {
beforeEach(() => {
mockShouldUseAssetBrowser.mockReturnValue(false)
mockIsAssetAPIEnabled.mockReturnValue(false)
vi.clearAllMocks()
})

View File

@@ -112,7 +112,7 @@ const specDescriptor = computed<{
const isAssetMode = computed(
() =>
assetService.shouldUseAssetBrowser(props.nodeType, props.widget.name) ||
(assetService.isAssetAPIEnabled() && props.widget.type === 'asset')
props.widget.type === 'asset'
)
const assetKind = computed(() => specDescriptor.value.kind)

View File

@@ -14,6 +14,9 @@ import { createMockWidget } from './widgetTestUtils'
const mockCheckState = vi.hoisted(() => vi.fn())
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
const mockAssetDataRefresh = vi.hoisted(() =>
vi.fn().mockResolvedValue(undefined)
)
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
@@ -47,7 +50,8 @@ vi.mock(
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
error: computed(() => null),
refresh: mockAssetDataRefresh
})
})
)
@@ -77,6 +81,26 @@ vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: vi.fn().mockResolvedValue([])
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue',
() => ({
default: {
name: 'FormDropdownStub',
props: ['items', 'displayItems', 'selected', 'placeholder'],
emits: ['update:is-open', 'update:selected', 'update:files'],
template: `
<div>
<button type="button" data-testid="fd-open" @click="$emit('update:is-open', true)">open</button>
<button type="button" data-testid="fd-close" @click="$emit('update:is-open', false)">close</button>
<span v-for="item in (displayItems || items || [])" :key="item.id">
<span v-if="(selected || new Set()).has(item.id)">{{ item.name }}</span>
</span>
</div>
`
}
})
)
const mockUpdateSelectedItems = vi.hoisted(() => vi.fn())
const mockHandleFilesUpdate = vi.hoisted(() => vi.fn())
@@ -144,6 +168,8 @@ describe('WidgetSelectDropdown', () => {
mockFilterSelectedRef.value = 'all'
mockUpdateSelectedItems.mockClear()
mockHandleFilesUpdate.mockClear()
mockMediaAssets.refresh.mockClear()
mockAssetDataRefresh.mockClear()
})
function renderComponent(
@@ -257,4 +283,72 @@ describe('WidgetSelectDropdown', () => {
expect(screen.queryByText('cat.png')).toBeNull()
})
})
describe('handleIsOpenUpdate', () => {
function renderAssetMode() {
const widget = createMockWidget<string | undefined>({
value: 'model_a.safetensors',
name: 'ckpt_name',
type: 'combo',
options: { values: [], nodeType: 'CheckpointLoaderSimple' }
})
return renderComponent(widget, 'model_a.safetensors', {
assetKind: 'model',
isAssetMode: true,
nodeType: 'CheckpointLoaderSimple'
})
}
function renderNonAssetMode() {
const widget = createMockWidget<string | undefined>({
value: 'cat.png',
name: 'image',
type: 'combo',
options: { values: ['cat.png', 'dog.png'] }
})
return renderComponent(widget, 'cat.png')
}
async function fireOpen() {
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
await user.click(screen.getByTestId('fd-open'))
}
async function fireClose() {
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
await user.click(screen.getByTestId('fd-close'))
}
it('skips outputMediaAssets refresh and triggers assetData refresh when asset-mode dropdown opens', async () => {
renderAssetMode()
await fireOpen()
expect(mockMediaAssets.refresh).not.toHaveBeenCalled()
expect(mockAssetDataRefresh).toHaveBeenCalledTimes(1)
})
it('refetches model assets on every asset-mode dropdown reopen (no stale cache)', async () => {
renderAssetMode()
await fireOpen()
await fireClose()
await fireOpen()
expect(mockAssetDataRefresh).toHaveBeenCalledTimes(2)
expect(mockMediaAssets.refresh).not.toHaveBeenCalled()
})
it('refreshes outputMediaAssets when non-asset-mode dropdown opens (preserved behavior)', async () => {
renderNonAssetMode()
await fireOpen()
expect(mockMediaAssets.refresh).toHaveBeenCalledTimes(1)
expect(mockAssetDataRefresh).not.toHaveBeenCalled()
})
it('does nothing when the dropdown is closed', async () => {
renderNonAssetMode()
await fireClose()
expect(mockMediaAssets.refresh).not.toHaveBeenCalled()
expect(mockAssetDataRefresh).not.toHaveBeenCalled()
})
})
})

View File

@@ -150,7 +150,12 @@ const acceptTypes = computed(() => {
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
function handleIsOpenUpdate(isOpen: boolean) {
if (isOpen && !outputMediaAssets.loading.value) {
if (!isOpen) return
if (props.isAssetMode) {
void assetData?.refresh()
return
}
if (!outputMediaAssets.loading.value) {
void outputMediaAssets.refresh()
}
}

View File

@@ -12,14 +12,12 @@ import { AssetKindKey } from './types'
import type { FormDropdownMenuItemProps } from './types'
const mockFindServerPreviewUrl = vi.hoisted(() => vi.fn())
const mockIsAssetPreviewSupported = vi.hoisted(() => vi.fn(() => true))
const intersectionCallbacks = vi.hoisted(
() => [] as Array<(entries: Array<{ isIntersecting: boolean }>) => void>
)
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
findServerPreviewUrl: (name: string) => mockFindServerPreviewUrl(name),
isAssetPreviewSupported: () => mockIsAssetPreviewSupported()
findServerPreviewUrl: (name: string) => mockFindServerPreviewUrl(name)
}))
vi.mock('@vueuse/core', () => ({
@@ -83,7 +81,6 @@ describe('FormDropdownMenuItem', () => {
beforeEach(() => {
intersectionCallbacks.length = 0
mockFindServerPreviewUrl.mockReset()
mockIsAssetPreviewSupported.mockReset().mockReturnValue(true)
})
describe('Label and name', () => {
@@ -167,14 +164,6 @@ describe('FormDropdownMenuItem', () => {
expect(img.getAttribute('src')).toBe('/api/preview/resolved.png')
})
it('skips lookup when asset preview is unsupported', async () => {
mockIsAssetPreviewSupported.mockReturnValue(false)
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
fireIntersection(true)
await flushPromises()
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('only looks up once for repeated intersection events', async () => {
mockFindServerPreviewUrl.mockResolvedValue(null)
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })

View File

@@ -5,10 +5,7 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import {
findServerPreviewUrl,
isAssetPreviewSupported
} from '@/platform/assets/utils/assetPreviewUtil'
import { findServerPreviewUrl } from '@/platform/assets/utils/assetPreviewUtil'
import { AssetKindKey } from './types'
import type { FormDropdownMenuItemProps } from './types'
@@ -40,7 +37,6 @@ function toLookupName(name: string): string {
}
async function resolveMeshPreview() {
if (!isAssetPreviewSupported()) return
const url = await findServerPreviewUrl(toLookupName(props.name))
if (url) resolvedMeshPreview.value = url
}

View File

@@ -1,41 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: () => [],
isModelLoading: () => false,
getError: () => undefined,
hasAssetKey: () => false,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, isLoading, error } = useAssetWidgetData(nodeType)
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
})
})

View File

@@ -4,10 +4,6 @@ import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockAssetsByKey = new Map<string, AssetItem[]>()
const mockLoadingByKey = new Map<string, boolean>()
const mockErrorByKey = new Map<string, Error | undefined>()
@@ -31,7 +27,7 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
describe('useAssetWidgetData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAssetsByKey.clear()
@@ -246,4 +242,55 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
expect(error.value).toBeNull()
})
})
describe('refresh()', () => {
it('refetches assets for the current node type, bypassing the initial-load cache guard', async () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
mockLoadingByKey.set(_nodeType, false)
return []
}
)
const { refresh, isLoading } = useAssetWidgetData(
'CheckpointLoaderSimple'
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledTimes(1)
await refresh()
expect(mockUpdateModelsForNodeType).toHaveBeenCalledTimes(2)
expect(mockUpdateModelsForNodeType).toHaveBeenLastCalledWith(
'CheckpointLoaderSimple'
)
})
it('skips refresh when a fetch is already in flight', async () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockInitializedKeys.add(_nodeType)
return []
}
)
const { refresh } = useAssetWidgetData('CheckpointLoaderSimple')
await nextTick()
mockLoadingByKey.set('CheckpointLoaderSimple', true)
mockUpdateModelsForNodeType.mockClear()
await refresh()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
})
it('no-ops when node type is undefined', async () => {
const { refresh } = useAssetWidgetData(undefined)
await refresh()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,7 +2,6 @@ import { computed, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -11,8 +10,6 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
* Provides reactive asset data based on node type with automatic category detection.
* Uses store-based caching to avoid duplicate fetches across multiple instances.
*
* Cloud-only composable - returns empty data when not in cloud environment.
*
* @param nodeType - ComfyUI node type (ref, getter, or plain value). Can be undefined.
* Accepts: ref('CheckpointLoaderSimple'), () => 'CheckpointLoaderSimple', or 'CheckpointLoaderSimple'
* @returns Reactive data including category, assets, dropdown items, loading state, and errors
@@ -20,61 +17,60 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
export function useAssetWidgetData(
nodeType: MaybeRefOrGetter<string | undefined>
) {
if (isCloud) {
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const category = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? modelToNodeStore.getCategoryForNodeType(resolvedType)
: undefined
})
const category = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? modelToNodeStore.getCategoryForNodeType(resolvedType)
: undefined
})
const assets = computed<AssetItem[]>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
})
const assets = computed<AssetItem[]>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
})
const isLoading = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
})
const isLoading = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
})
const error = computed<Error | null>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
})
const error = computed<Error | null>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
})
watch(
() => toValue(nodeType),
async (currentNodeType) => {
if (!currentNodeType) {
return
}
watch(
() => toValue(nodeType),
async (currentNodeType) => {
if (!currentNodeType) {
return
}
const isLoading = assetsStore.isModelLoading(currentNodeType)
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
const isLoading = assetsStore.isModelLoading(currentNodeType)
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},
{ immediate: true }
)
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},
{ immediate: true }
)
return {
category,
assets,
isLoading,
error
}
async function refresh(): Promise<void> {
const currentNodeType = toValue(nodeType)
if (!currentNodeType) return
if (assetsStore.isModelLoading(currentNodeType)) return
await assetsStore.updateModelsForNodeType(currentNodeType)
}
return {
category: computed(() => undefined),
assets: computed<AssetItem[]>(() => []),
isLoading: computed(() => false),
error: computed(() => null)
category,
assets,
isLoading,
error,
refresh
}
}

View File

@@ -25,7 +25,6 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
}
// Use vi.hoisted() to ensure mock state is initialized before mocks
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
@@ -41,12 +40,6 @@ vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistributionState.isCloud
}
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: vi.fn(() => ({
get inputAssets() {
@@ -147,7 +140,6 @@ describe('useComboWidget', () => {
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
vi.mocked(useAssetBrowserDialog).mockClear()
mockDistributionState.isCloud = false
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
mockUpdateInputs.mockClear()
@@ -174,8 +166,7 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when asset API is disabled', () => {
mockDistributionState.isCloud = true
it('should create normal combo widget when asset browser is not eligible', () => {
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
@@ -201,15 +192,14 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
describe('cloud asset browser widget', () => {
describe('asset browser widget', () => {
// "Select model" is the fallback from t('widgets.selectModel')
// in createAssetWidget when defaultValue is undefined.
const PLACEHOLDER = 'Select model'
function setupCloudAssetWidget(
function setupAssetBrowserWidget(
inputSpecOverrides: Partial<InputSpec> = {}
) {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
@@ -238,7 +228,7 @@ describe('useComboWidget', () => {
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
const { mockNode } = setupAssetBrowserWidget({
options: ['model1.safetensors', 'model2.safetensors']
})
@@ -254,38 +244,37 @@ describe('useComboWidget', () => {
)
})
it('should use first cloud asset as default instead of server combo options', () => {
it('should use first asset as default instead of server combo options', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
createMockAssetItem({ name: 'asset_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
const { mockNode } = setupAssetBrowserWidget({
options: ['local_only_model.safetensors']
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
expect(getWidgetDefault(mockNode)).toBe('asset_model.safetensors')
})
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
it('should fallback to assets[0] when inputSpec.default not in assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
createMockAssetItem({ name: 'asset_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
default: 'not_in_cloud.safetensors'
const { mockNode } = setupAssetBrowserWidget({
default: 'not_in_assets.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
expect(getWidgetDefault(mockNode)).toBe('asset_model.safetensors')
})
it('should prefer inputSpec.default when it exists in cloud assets', () => {
it('should prefer inputSpec.default when it exists in assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'other_model.safetensors' }),
createMockAssetItem({ name: 'fallback.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
const { mockNode } = setupAssetBrowserWidget({
default: 'fallback.safetensors'
})
@@ -295,18 +284,17 @@ describe('useComboWidget', () => {
it('should create asset browser widget when default value provided without options', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
const { mockNode } = setupAssetBrowserWidget({
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
it('should fallback to placeholder when cloud assets not loaded', () => {
it('should fallback to placeholder when assets not loaded', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
const { mockNode } = setupAssetBrowserWidget({
options: ['local_model.safetensors']
})
@@ -315,7 +303,6 @@ describe('useComboWidget', () => {
})
it('should show Select model when asset widget has undefined current value', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
@@ -343,7 +330,7 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
describe('cloud input asset mapping', () => {
describe('input asset mapping', () => {
const HASH_FILENAME =
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
const HASH_FILENAME_2 =
@@ -354,10 +341,8 @@ describe('useComboWidget', () => {
{ nodeClass: 'LoadVideo', inputName: 'video' },
{ nodeClass: 'LoadAudio', inputName: 'audio' }
])(
'should create combo widget with getOptionLabel for $nodeClass in cloud',
'should create combo widget with getOptionLabel for $nodeClass',
({ nodeClass, inputName }) => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'combo',
@@ -387,9 +372,7 @@ describe('useComboWidget', () => {
}
)
it('should keep the original options object for cloud input mappings', () => {
mockDistributionState.isCloud = true
it('should keep the original options object for input mappings', () => {
const constructor = useComboWidget()
const mockNode = createMockNode('LoadImage')
const inputSpec = createMockInputSpec({
@@ -405,7 +388,6 @@ describe('useComboWidget', () => {
})
it("should format option labels using store's getInputName function", () => {
mockDistributionState.isCloud = true
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
const constructor = useComboWidget()
@@ -445,9 +427,7 @@ describe('useComboWidget', () => {
expect(result).toBe('Beautiful Sunset.png')
})
it('should create normal combo widget for non-input nodes in cloud', () => {
mockDistributionState.isCloud = true
it('should create normal combo widget for non-input nodes', () => {
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('SomeOtherNode')
@@ -469,34 +449,7 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget for LoadImage in OSS', () => {
mockDistributionState.isCloud = false
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'image',
HASH_FILENAME,
expect.any(Function),
{
values: [HASH_FILENAME, HASH_FILENAME_2]
}
)
expect(widget).toBe(mockWidget)
})
it('should trigger lazy load for cloud input nodes', () => {
mockDistributionState.isCloud = true
it('should trigger lazy load for input nodes', () => {
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
@@ -515,7 +468,6 @@ describe('useComboWidget', () => {
})
it('should not trigger lazy load if assets already loading', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = true
@@ -534,7 +486,6 @@ describe('useComboWidget', () => {
})
it('should not trigger lazy load if assets already loaded', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = [
createMockAssetItem({
id: 'asset-123',

View File

@@ -8,7 +8,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { isCloud } from '@/platform/distribution/types'
import type {
ComboInputSpec,
InputSpec
@@ -106,11 +105,11 @@ const addMultiSelectWidget = (
}
/**
* Resolve the default value for a cloud asset widget.
* Priority: inputSpec.default (if present in cloud assets) → first cloud
* asset → undefined (shows placeholder).
* Resolve the default value for an asset widget.
* Priority: inputSpec.default (if present in assets) → first asset → undefined
* (shows placeholder).
*/
function resolveCloudDefault(
function resolveAssetDefault(
nodeType: string,
specDefault: string | undefined
): string | undefined {
@@ -119,7 +118,6 @@ function resolveCloudDefault(
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
if (inAssets) return specDefault
}
// empty filename → undefined (shows placeholder)
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
return filename || undefined
}
@@ -213,21 +211,19 @@ const addComboWidget = (
): IBaseWidget => {
const defaultValue = getDefaultValue(inputSpec)
if (isCloud) {
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
// Default from cloud assets, not from server combo options.
// Server options list local files that may not exist in the user's
// cloud asset library, leading to missing-model errors on undo/reload.
const cloudDefault = resolveCloudDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
}
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
// Default from asset library, not from server combo options.
// Server options list local files that may not exist in the user's
// asset library, leading to missing-model errors on undo/reload.
const assetDefault = resolveAssetDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, assetDefault)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
}
// Standard combo widget

View File

@@ -319,7 +319,8 @@ describe('useWidgetSelectItems', () => {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
error: computed(() => null),
refresh: async () => {}
}
const { dropdownItems } = useWidgetSelectItems(
@@ -354,7 +355,8 @@ describe('useWidgetSelectItems', () => {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
error: computed(() => null),
refresh: async () => {}
}
const { dropdownItems } = useWidgetSelectItems(
@@ -379,7 +381,8 @@ describe('useWidgetSelectItems', () => {
category: computed(() => 'checkpoints'),
assets: computed(() => [] as AssetItem[]),
isLoading: computed(() => false),
error: computed(() => null)
error: computed(() => null),
refresh: async () => {}
}
const { dropdownItems } = useWidgetSelectItems(
@@ -408,7 +411,8 @@ describe('useWidgetSelectItems', () => {
category: computed(() => 'checkpoints'),
assets: computed(() => mockAssetsData.items),
isLoading: computed(() => false),
error: computed(() => null)
error: computed(() => null),
refresh: async () => {}
}
const { displayItems, selectedSet } = useWidgetSelectItems(

View File

@@ -425,7 +425,6 @@ const zSettings = z.object({
'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy.AppBuilder.VueNodeSwitchDismissed': z.boolean(),
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy.Queue.QPOV2': z.boolean(),
'Comfy.Queue.ShowRunProgressBar': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),

View File

@@ -1505,7 +1505,7 @@ export class ComfyApp {
): Promise<void> {
const missingMediaStore = useMissingMediaStore()
const activeWf = useWorkspaceStore().workflow.activeWorkflow
const allCandidates = scanAllMediaCandidates(this.rootGraph, isCloud)
const allCandidates = scanAllMediaCandidates(this.rootGraph)
// Drop candidates whose enclosing subgraph is muted/bypassed.
const candidates = allCandidates.filter((c) =>
isAncestorPathActive(this.rootGraph, String(c.nodeId))
@@ -1520,7 +1520,7 @@ export class ComfyApp {
if (pending) {
const controller = missingMediaStore.createVerificationAbortController()
void verifyMediaCandidates(candidates, {
isCloud,
allowCompactSuffix: isCloud,
signal: controller.signal
})
.then(() => {

View File

@@ -36,12 +36,8 @@ vi.mock('@/platform/assets/services/assetService', () => ({
OUTPUT_TAG: 'output'
}))
// Mock distribution type - hoisted so it can be changed per test
const mockIsCloud = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
isCloud: true
}))
// Mock modelToNodeStore with proper node providers and category lookups
@@ -155,14 +151,6 @@ vi.mock('@/stores/queueStore', () => ({
// Mock asset mappers - add unique timestamps
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
mapInputFileToAssetItem: vi.fn((name, index, type) => ({
id: `${type}-${index}`,
name,
size: 0,
created_at: new Date(Date.now() - index * 1000).toISOString(),
tags: [type],
preview_url: `http://test.com/${name}`
})),
mapTaskOutputToAssetItem: vi.fn((task, output) => {
const index = parseInt(task.jobId.split('_')[1]) || 0
return {
@@ -770,17 +758,12 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('assetsStore - Model Assets Cache (Cloud)', () => {
describe('assetsStore - Model Assets Cache', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockIsCloud.value = true
vi.clearAllMocks()
})
afterEach(() => {
mockIsCloud.value = false
})
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
id,
name: `asset-${id}`,
@@ -1454,25 +1437,20 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
describe('getInputName', () => {
it('resolves a hashed filename to the human-readable name when the input asset is in the cache', async () => {
mockIsCloud.value = true
try {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
{
id: 'input-1',
name: 'cute-puppy.png',
asset_hash: 'abc123def.png',
tags: ['input']
}
])
await store.updateInputs()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
{
id: 'input-1',
name: 'cute-puppy.png',
asset_hash: 'abc123def.png',
tags: ['input']
}
])
await store.updateInputs()
expect(store.getInputName('abc123def.png')).toBe('cute-puppy.png')
} finally {
mockIsCloud.value = false
}
expect(store.getInputName('abc123def.png')).toBe('cute-puppy.png')
})
it('falls back to the original filename when the input asset is not cached', () => {
@@ -1481,27 +1459,22 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
})
})
describe('updateInputs cloud routing', () => {
it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => {
mockIsCloud.value = true
try {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
describe('updateInputs', () => {
it('reads from assetService.getAssetsByTag with limit 100', async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([])
await store.updateInputs()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([])
await store.updateInputs()
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledWith(
'input',
false,
{ limit: 100 }
)
expect(
assetService.invalidateInputAssetsIncludingPublic
).toHaveBeenCalledOnce()
} finally {
mockIsCloud.value = false
}
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledWith(
'input',
false,
{ limit: 100 }
)
expect(
assetService.invalidateInputAssetsIncludingPublic
).toHaveBeenCalledOnce()
})
})
})

View File

@@ -2,10 +2,7 @@ import { useAsyncState, whenever } from '@vueuse/core'
import { difference } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, reactive, ref, shallowReactive } from 'vue'
import {
mapInputFileToAssetItem,
mapTaskOutputToAssetItem
} from '@/platform/assets/composables/media/assetMappers'
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
import type {
AssetItem,
TagsOperationResult
@@ -16,7 +13,6 @@ import {
assetService
} from '@/platform/assets/services/assetService'
import type { PaginationOptions } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { api } from '@/scripts/api'
@@ -26,30 +22,7 @@ import { useModelToNodeStore } from './modelToNodeStore'
const INPUT_LIMIT = 100
/**
* Fetch input files from the internal API (OSS version)
*/
async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
const response = await fetch(api.internalURL('/files/input'), {
headers: {
'Comfy-User': api.user
}
})
if (!response.ok) {
throw new Error('Failed to fetch input files')
}
const filenames: string[] = await response.json()
return filenames.map((name, index) =>
mapInputFileToAssetItem(name, index, 'input')
)
}
/**
* Fetch input files from cloud service
*/
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
async function fetchInputFiles(): Promise<AssetItem[]> {
return await assetService.getAssetsByTag(INPUT_TAG, false, {
limit: INPUT_LIMIT
})
@@ -123,10 +96,6 @@ export const useAssetsStore = defineStore('assets', () => {
const loadedIds = shallowReactive(new Set<string>())
const fetchInputFiles = isCloud
? fetchInputFilesFromCloud
: fetchInputFilesFromAPI
const {
state: inputAssets,
isLoading: inputLoading,
@@ -385,418 +354,400 @@ export const useAssetsStore = defineStore('assets', () => {
* Cloud-only feature - empty Maps in desktop builds
*/
const getModelState = () => {
if (isCloud) {
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
const assetsArrayCache = new Map<
string,
{ source: Map<string, AssetItem>; array: AssetItem[] }
>()
const assetsArrayCache = new Map<
string,
{ source: Map<string, AssetItem>; array: AssetItem[] }
>()
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
const pendingPromiseByCategory = new Map<string, Promise<void>>()
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
const pendingPromiseByCategory = new Map<string, Promise<void>>()
function createState(
existingAssets?: Map<string, AssetItem>
): ModelPaginationState {
const assets = new Map(existingAssets)
return reactive({
assets,
offset: 0,
hasMore: true,
isLoading: true
})
function createState(
existingAssets?: Map<string, AssetItem>
): ModelPaginationState {
const assets = new Map(existingAssets)
return reactive({
assets,
offset: 0,
hasMore: true,
isLoading: true
})
}
function isStale(category: string, state: ModelPaginationState): boolean {
const committed = modelStateByCategory.value.get(category)
const pending = pendingRequestByCategory.get(category)
return committed !== state && pending !== state
}
const EMPTY_ASSETS: AssetItem[] = []
/**
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
* @returns The category or undefined if not resolvable
*/
function resolveCategory(key: string): string | undefined {
if (key.startsWith('tag:')) {
return key
}
return modelToNodeStore.getCategoryForNodeType(key)
}
/**
* Get assets by nodeType or tag key.
* Translates nodeType to category internally for cache lookup.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
*/
function getAssets(key: string): AssetItem[] {
const category = resolveCategory(key)
if (!category) return EMPTY_ASSETS
const state = modelStateByCategory.value.get(category)
const assetsMap = state?.assets
if (!assetsMap) return EMPTY_ASSETS
const cached = assetsArrayCache.get(category)
if (cached && cached.source === assetsMap) {
return cached.array
}
function isStale(category: string, state: ModelPaginationState): boolean {
const committed = modelStateByCategory.value.get(category)
const pending = pendingRequestByCategory.get(category)
return committed !== state && pending !== state
const array = Array.from(assetsMap.values())
assetsArrayCache.set(category, { source: assetsMap, array })
return array
}
function isLoading(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.isLoading ?? false
}
function getError(key: string): Error | undefined {
const category = resolveCategory(key)
if (!category) return undefined
return modelStateByCategory.value.get(category)?.error
}
function hasMore(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.hasMore ?? false
}
function hasAssetKey(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.has(category)
}
/**
* Check if a category exists in the cache.
* Checks both direct category keys and tag-prefixed keys.
* @param category The category to check (e.g., 'checkpoints', 'loras')
*/
function hasCategory(category: string): boolean {
return (
modelStateByCategory.value.has(category) ||
modelStateByCategory.value.has(`tag:${category}`)
)
}
/**
* Internal helper to fetch and cache assets for a category.
* Loads first batch immediately, then progressively loads remaining batches.
* Keeps existing data visible until new data is successfully fetched.
*
* Concurrent calls for the same category are short-circuited: if a request
* is already in progress (tracked via pendingRequestByCategory), subsequent
* calls return immediately to avoid redundant work.
*/
async function updateModelsForCategory(
category: string,
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
if (pendingPromiseByCategory.has(category)) {
return pendingPromiseByCategory.get(category)!
}
const EMPTY_ASSETS: AssetItem[] = []
const existingState = modelStateByCategory.value.get(category)
const state = createState(existingState?.assets)
/**
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
* @returns The category or undefined if not resolvable
*/
function resolveCategory(key: string): string | undefined {
if (key.startsWith('tag:')) {
return key
}
return modelToNodeStore.getCategoryForNodeType(key)
const seenIds = new Set<string>()
const hasExistingData = modelStateByCategory.value.has(category)
if (hasExistingData) {
pendingRequestByCategory.set(category, state)
} else {
// Also track in pending map for initial loads to prevent concurrent calls
pendingRequestByCategory.set(category, state)
modelStateByCategory.value.set(category, state)
}
/**
* Get assets by nodeType or tag key.
* Translates nodeType to category internally for cache lookup.
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
*/
function getAssets(key: string): AssetItem[] {
const category = resolveCategory(key)
if (!category) return EMPTY_ASSETS
async function loadBatches(): Promise<void> {
while (state.hasMore) {
try {
const newAssets = await fetcher({
limit: MODEL_BATCH_SIZE,
offset: state.offset
})
const state = modelStateByCategory.value.get(category)
const assetsMap = state?.assets
if (!assetsMap) return EMPTY_ASSETS
if (isStale(category, state)) return
const cached = assetsArrayCache.get(category)
if (cached && cached.source === assetsMap) {
return cached.array
}
const array = Array.from(assetsMap.values())
assetsArrayCache.set(category, { source: assetsMap, array })
return array
}
function isLoading(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.isLoading ?? false
}
function getError(key: string): Error | undefined {
const category = resolveCategory(key)
if (!category) return undefined
return modelStateByCategory.value.get(category)?.error
}
function hasMore(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.get(category)?.hasMore ?? false
}
function hasAssetKey(key: string): boolean {
const category = resolveCategory(key)
if (!category) return false
return modelStateByCategory.value.has(category)
}
/**
* Check if a category exists in the cache.
* Checks both direct category keys and tag-prefixed keys.
* @param category The category to check (e.g., 'checkpoints', 'loras')
*/
function hasCategory(category: string): boolean {
return (
modelStateByCategory.value.has(category) ||
modelStateByCategory.value.has(`tag:${category}`)
)
}
/**
* Internal helper to fetch and cache assets for a category.
* Loads first batch immediately, then progressively loads remaining batches.
* Keeps existing data visible until new data is successfully fetched.
*
* Concurrent calls for the same category are short-circuited: if a request
* is already in progress (tracked via pendingRequestByCategory), subsequent
* calls return immediately to avoid redundant work.
*/
async function updateModelsForCategory(
category: string,
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
if (pendingPromiseByCategory.has(category)) {
return pendingPromiseByCategory.get(category)!
}
const existingState = modelStateByCategory.value.get(category)
const state = createState(existingState?.assets)
const seenIds = new Set<string>()
const hasExistingData = modelStateByCategory.value.has(category)
if (hasExistingData) {
pendingRequestByCategory.set(category, state)
} else {
// Also track in pending map for initial loads to prevent concurrent calls
pendingRequestByCategory.set(category, state)
modelStateByCategory.value.set(category, state)
}
async function loadBatches(): Promise<void> {
while (state.hasMore) {
try {
const newAssets = await fetcher({
limit: MODEL_BATCH_SIZE,
offset: state.offset
})
if (isStale(category, state)) return
const isFirstBatch = state.offset === 0
if (isFirstBatch) {
assetsArrayCache.delete(category)
if (hasExistingData) {
pendingRequestByCategory.delete(category)
modelStateByCategory.value.set(category, state)
}
const isFirstBatch = state.offset === 0
if (isFirstBatch) {
assetsArrayCache.delete(category)
if (hasExistingData) {
pendingRequestByCategory.delete(category)
modelStateByCategory.value.set(category, state)
}
// Merge new assets into existing map and track seen IDs
for (const asset of newAssets) {
seenIds.add(asset.id)
state.assets.set(asset.id, asset)
}
state.assets = new Map(state.assets)
state.offset += newAssets.length
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
if (isFirstBatch) {
state.isLoading = false
}
if (state.hasMore) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
} catch (err) {
if (isStale(category, state)) return
console.error(`Error loading batch for ${category}:`, err)
state.error = err instanceof Error ? err : new Error(String(err))
state.hasMore = false
state.isLoading = false
pendingRequestByCategory.delete(category)
return
}
}
const staleIds = [...state.assets.keys()].filter(
(id) => !seenIds.has(id)
)
for (const id of staleIds) {
state.assets.delete(id)
// Merge new assets into existing map and track seen IDs
for (const asset of newAssets) {
seenIds.add(asset.id)
state.assets.set(asset.id, asset)
}
state.assets = new Map(state.assets)
state.offset += newAssets.length
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
if (isFirstBatch) {
state.isLoading = false
}
if (state.hasMore) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
} catch (err) {
if (isStale(category, state)) return
console.error(`Error loading batch for ${category}:`, err)
state.error = err instanceof Error ? err : new Error(String(err))
state.hasMore = false
state.isLoading = false
pendingRequestByCategory.delete(category)
return
}
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
}
const promise = loadBatches().finally(() => {
pendingPromiseByCategory.delete(category)
})
pendingPromiseByCategory.set(category, promise)
await promise
}
/**
* Fetch and cache model assets for a specific node type.
* Translates nodeType to category internally - multiple node types
* sharing the same category will share the same cache entry.
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
*/
async function updateModelsForNodeType(nodeType: string): Promise<void> {
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
if (!category) return
// Use category as cache key but fetch using nodeType for API compatibility
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsForNodeType(nodeType, opts)
const staleIds = [...state.assets.keys()].filter(
(id) => !seenIds.has(id)
)
}
/**
* Fetch and cache model assets for a specific tag
* @param tag The tag to fetch assets for (e.g., 'models')
*/
async function updateModelsForTag(tag: string): Promise<void> {
const category = `tag:${tag}`
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsByTag(tag, true, opts)
)
}
/**
* Invalidate the cache for a specific category.
* Forces a refetch on next access.
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
*/
function invalidateCategory(category: string): void {
modelStateByCategory.value.delete(category)
for (const id of staleIds) {
state.assets.delete(id)
}
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
}
const promise = loadBatches().finally(() => {
pendingPromiseByCategory.delete(category)
}
})
pendingPromiseByCategory.set(category, promise)
await promise
}
/**
* Optimistically update an asset in the cache
* @param assetId The asset ID to update
* @param updates Partial asset data to merge
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
*/
function updateAssetInCache(
assetId: string,
updates: Partial<AssetItem>,
cacheKey?: string
) {
const category = cacheKey ? resolveCategory(cacheKey) : undefined
if (cacheKey && !category) return
/**
* Fetch and cache model assets for a specific node type.
* Translates nodeType to category internally - multiple node types
* sharing the same category will share the same cache entry.
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
*/
async function updateModelsForNodeType(nodeType: string): Promise<void> {
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
if (!category) return
const categoriesToCheck = category
? [category]
: Array.from(modelStateByCategory.value.keys())
// Use category as cache key but fetch using nodeType for API compatibility
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsForNodeType(nodeType, opts)
)
}
for (const cat of categoriesToCheck) {
const state = modelStateByCategory.value.get(cat)
if (!state?.assets) continue
/**
* Fetch and cache model assets for a specific tag
* @param tag The tag to fetch assets for (e.g., 'models')
*/
async function updateModelsForTag(tag: string): Promise<void> {
const category = `tag:${tag}`
await updateModelsForCategory(category, (opts) =>
assetService.getAssetsByTag(tag, true, opts)
)
}
const existingAsset = state.assets.get(assetId)
if (existingAsset) {
const updatedAsset = { ...existingAsset, ...updates }
state.assets.set(assetId, updatedAsset)
assetsArrayCache.delete(cat)
if (cacheKey) return
}
/**
* Invalidate the cache for a specific category.
* Forces a refetch on next access.
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
*/
function invalidateCategory(category: string): void {
modelStateByCategory.value.delete(category)
assetsArrayCache.delete(category)
pendingRequestByCategory.delete(category)
pendingPromiseByCategory.delete(category)
}
/**
* Optimistically update an asset in the cache
* @param assetId The asset ID to update
* @param updates Partial asset data to merge
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
*/
function updateAssetInCache(
assetId: string,
updates: Partial<AssetItem>,
cacheKey?: string
) {
const category = cacheKey ? resolveCategory(cacheKey) : undefined
if (cacheKey && !category) return
const categoriesToCheck = category
? [category]
: Array.from(modelStateByCategory.value.keys())
for (const cat of categoriesToCheck) {
const state = modelStateByCategory.value.get(cat)
if (!state?.assets) continue
const existingAsset = state.assets.get(assetId)
if (existingAsset) {
const updatedAsset = { ...existingAsset, ...updates }
state.assets.set(assetId, updatedAsset)
assetsArrayCache.delete(cat)
if (cacheKey) return
}
}
/**
* Update asset metadata with optimistic cache update
* @param asset The asset to update
* @param userMetadata The user_metadata to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetMetadata(
asset: AssetItem,
userMetadata: Record<string, unknown>,
cacheKey?: string
) {
const originalMetadata = asset.user_metadata
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
try {
const updatedAsset = await assetService.updateAsset(asset.id, {
user_metadata: userMetadata
})
updateAssetInCache(asset.id, updatedAsset, cacheKey)
} catch (error) {
console.error('Failed to update asset metadata:', error)
updateAssetInCache(
asset.id,
{ user_metadata: originalMetadata },
cacheKey
)
}
}
/**
* Update asset tags using add/remove endpoints
* @param asset The asset to update (used to read current tags)
* @param newTags The desired tags array
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetTags(
asset: AssetItem,
newTags: string[],
cacheKey?: string
) {
const originalTags = asset.tags
const tagsToAdd = difference(newTags, originalTags)
const tagsToRemove = difference(originalTags, newTags)
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
let removedTagsOnServer: string[] = []
try {
let removeResult: TagsOperationResult | undefined
if (tagsToRemove.length > 0) {
removeResult = await assetService.removeAssetTags(
asset.id,
tagsToRemove
)
removedTagsOnServer = removeResult.removed ?? tagsToRemove
}
const addResult =
tagsToAdd.length > 0
? await assetService.addAssetTags(asset.id, tagsToAdd)
: undefined
const finalTags = (addResult ?? removeResult)?.total_tags
if (finalTags) {
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
}
} catch (error) {
console.error('Failed to update asset tags:', error)
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
if (removedTagsOnServer.length > 0) {
try {
await assetService.addAssetTags(asset.id, removedTagsOnServer)
} catch (compensationError) {
console.error(
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
compensationError
)
const categoriesToInvalidate = new Set<string>()
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
if (resolved) {
categoriesToInvalidate.add(resolved)
}
for (const [
category,
state
] of modelStateByCategory.value.entries()) {
if (state.assets?.has(asset.id)) {
categoriesToInvalidate.add(category)
}
}
for (const category of categoriesToInvalidate) {
invalidateCategory(category)
}
}
}
}
}
/**
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
* Clears the category cache and tag-based caches so next access triggers refetch
* @param category The model category to invalidate (e.g., 'checkpoints')
*/
function invalidateModelsForCategory(category: string): void {
invalidateCategory(category)
invalidateCategory(`tag:${category}`)
invalidateCategory('tag:models')
}
return {
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags,
invalidateModelsForCategory
}
}
const emptyAssets: AssetItem[] = []
/**
* Update asset metadata with optimistic cache update
* @param asset The asset to update
* @param userMetadata The user_metadata to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetMetadata(
asset: AssetItem,
userMetadata: Record<string, unknown>,
cacheKey?: string
) {
const originalMetadata = asset.user_metadata
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
try {
const updatedAsset = await assetService.updateAsset(asset.id, {
user_metadata: userMetadata
})
updateAssetInCache(asset.id, updatedAsset, cacheKey)
} catch (error) {
console.error('Failed to update asset metadata:', error)
updateAssetInCache(
asset.id,
{ user_metadata: originalMetadata },
cacheKey
)
}
}
/**
* Update asset tags using add/remove endpoints
* @param asset The asset to update (used to read current tags)
* @param newTags The desired tags array
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetTags(
asset: AssetItem,
newTags: string[],
cacheKey?: string
) {
const originalTags = asset.tags
const tagsToAdd = difference(newTags, originalTags)
const tagsToRemove = difference(originalTags, newTags)
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
let removedTagsOnServer: string[] = []
try {
let removeResult: TagsOperationResult | undefined
if (tagsToRemove.length > 0) {
removeResult = await assetService.removeAssetTags(
asset.id,
tagsToRemove
)
removedTagsOnServer = removeResult.removed ?? tagsToRemove
}
const addResult =
tagsToAdd.length > 0
? await assetService.addAssetTags(asset.id, tagsToAdd)
: undefined
const finalTags = (addResult ?? removeResult)?.total_tags
if (finalTags) {
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
}
} catch (error) {
console.error('Failed to update asset tags:', error)
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
if (removedTagsOnServer.length > 0) {
try {
await assetService.addAssetTags(asset.id, removedTagsOnServer)
} catch (compensationError) {
console.error(
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
compensationError
)
const categoriesToInvalidate = new Set<string>()
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
if (resolved) {
categoriesToInvalidate.add(resolved)
}
for (const [
category,
state
] of modelStateByCategory.value.entries()) {
if (state.assets?.has(asset.id)) {
categoriesToInvalidate.add(category)
}
}
for (const category of categoriesToInvalidate) {
invalidateCategory(category)
}
}
}
}
}
/**
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
* Clears the category cache and tag-based caches so next access triggers refetch
* @param category The model category to invalidate (e.g., 'checkpoints')
*/
function invalidateModelsForCategory(category: string): void {
invalidateCategory(category)
invalidateCategory(`tag:${category}`)
invalidateCategory('tag:models')
}
return {
getAssets: () => emptyAssets,
isLoading: () => false,
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
hasCategory: () => false,
updateModelsForNodeType: async () => {},
invalidateCategory: () => {},
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {},
invalidateModelsForCategory: () => {}
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
hasCategory,
updateModelsForNodeType,
updateModelsForTag,
invalidateCategory,
updateAssetMetadata,
updateAssetTags,
invalidateModelsForCategory
}
}

View File

@@ -3,15 +3,12 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { useModelStore } from '@/stores/modelStore'
// Mock the api
vi.mock('@/scripts/api', () => ({
api: {
getModels: vi.fn(),
getModelFolders: vi.fn(),
viewMetadata: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
@@ -27,37 +24,8 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
// Mock the settingStore
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn()
}))
function enableMocks(useAssetAPI = false) {
// Mock settingStore to return the useAssetAPI setting
const mockSettingStore = {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'Comfy.Assets.UseAssetAPI') {
return useAssetAPI
}
return false
})
}
vi.mocked(useSettingStore, { partial: true }).mockReturnValue(
mockSettingStore
)
// Mock experimental API - returns objects with name and folders properties
vi.mocked(api.getModels).mockResolvedValue([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
{ name: 'noinfo.safetensors', pathIndex: 0 }
])
vi.mocked(api.getModelFolders).mockResolvedValue([
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
])
// Mock asset API - also returns objects with name and folders properties
function enableMocks() {
// Mock asset API - returns objects with name and folders properties
vi.mocked(assetService.getAssetModelFolders).mockResolvedValue([
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
@@ -141,11 +109,11 @@ describe('useModelStore', () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
expect(api.getModels).toHaveBeenCalledTimes(0)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
})
describe('refreshModelFolder', () => {
@@ -154,9 +122,9 @@ describe('useModelStore', () => {
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
vi.mocked(api.getModels).mockResolvedValueOnce([
vi.mocked(assetService.getAssetModels).mockResolvedValueOnce([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
{ name: 'noinfo.safetensors', pathIndex: 0 },
@@ -165,7 +133,7 @@ describe('useModelStore', () => {
await store.refreshModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(2)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(2)
const folder = await store.getLoadedModelFolder('checkpoints')
expect(Object.keys(folder!.models)).toHaveLength(4)
expect(folder!.models['0/new-upload.safetensors']).toBeDefined()
@@ -175,12 +143,12 @@ describe('useModelStore', () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
await store.refreshModelFolder('loras')
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(api.getModels).not.toHaveBeenCalled()
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(2)
expect(assetService.getAssetModels).not.toHaveBeenCalled()
})
})
@@ -190,13 +158,15 @@ describe('useModelStore', () => {
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(1)
await store.refresh()
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(api.getModels).toHaveBeenCalledTimes(2)
expect(api.getModels).toHaveBeenLastCalledWith('checkpoints')
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(2)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(2)
expect(assetService.getAssetModels).toHaveBeenLastCalledWith(
'checkpoints'
)
})
it('does not load folders that were never opened', async () => {
@@ -206,38 +176,20 @@ describe('useModelStore', () => {
await store.refresh()
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(api.getModels).not.toHaveBeenCalled()
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(2)
expect(assetService.getAssetModels).not.toHaveBeenCalled()
})
})
describe('API switching functionality', () => {
it('should use experimental API for complete workflow when UseAssetAPI setting is false', async () => {
enableMocks(false) // useAssetAPI = false
describe('asset API usage', () => {
it('uses the asset API for model folders and models', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(0)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
enableMocks(true) // useAssetAPI = true
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledWith('checkpoints')
expect(api.getModelFolders).toHaveBeenCalledTimes(0)
expect(api.getModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})

View File

@@ -3,7 +3,6 @@ import { computed, ref } from 'vue'
import type { ModelFile } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
@@ -193,7 +192,6 @@ export class ModelFolder {
/** Model store handler, wraps individual per-folder model stores */
export const useModelStore = defineStore('models', () => {
const settingStore = useSettingStore()
const modelFolderNames = ref<string[]>([])
const modelFolderByName = ref<Record<string, ModelFolder>>({})
const modelFolders = computed<ModelFolder[]>(() =>
@@ -206,21 +204,14 @@ export const useModelStore = defineStore('models', () => {
)
function createGetModelsFunc(): (folder: string) => Promise<ModelFile[]> {
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
return useAssetAPI
? (folder) => assetService.getAssetModels(folder)
: (folder) => api.getModels(folder)
return (folder) => assetService.getAssetModels(folder)
}
/**
* Loads the model folders from the server
*/
async function loadModelFolders() {
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
const resData = useAssetAPI
? await assetService.getAssetModelFolders()
: await api.getModelFolders()
const resData = await assetService.getAssetModelFolders()
modelFolderNames.value = resData.map((folder) => folder.name)
modelFolderByName.value = {}
const getModelsFunc = createGetModelsFunc()

View File

@@ -73,13 +73,9 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
versionAdded: '1.3.9',
category: 'view-controls' as const,
function: async () => {
const settingStore = useSettingStore()
const commandStore = useCommandStore()
if (
tab.id === 'model-library' &&
settingStore.get('Comfy.Assets.UseAssetAPI')
) {
if (tab.id === 'model-library') {
await commandStore.commands
.find((cmd) => cmd.id === 'Comfy.BrowseModelAssets')
?.function?.()

View File

@@ -0,0 +1,48 @@
import { vi } from 'vitest'
import type {
FeatureFlags,
useFeatureFlags
} from '@/composables/useFeatureFlags'
/**
* Mock implementation of `useFeatureFlags()` for unit/component tests.
*
* All flags default to their production opt-in default (mostly `false`);
* pass overrides to enable specific ones for the test.
*
* @example
* vi.mock('@/composables/useFeatureFlags', () => ({
* useFeatureFlags: () => mockFeatureFlags({ assetRenameEnabled: true })
* }))
*/
export function mockFeatureFlags(
overrides: Partial<FeatureFlags> = {}
): ReturnType<typeof useFeatureFlags> {
const flags: FeatureFlags = {
supportsPreviewMetadata: false,
maxUploadSize: 0,
supportsManagerV4: false,
modelUploadButtonEnabled: false,
assetRenameEnabled: false,
privateModelsEnabled: false,
onboardingSurveyEnabled: false,
linearToggleEnabled: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
nodeReplacementsEnabled: false,
nodeLibraryEssentialsEnabled: false,
workflowSharingEnabled: false,
comfyHubUploadEnabled: false,
comfyHubProfileGateEnabled: false,
showSignInButton: undefined,
...overrides
}
return {
flags,
featureFlag: vi.fn() as unknown as ReturnType<
typeof useFeatureFlags
>['featureFlag']
}
}