Compare commits

...

44 Commits

Author SHA1 Message Date
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
81 changed files with 2016 additions and 1935 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,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

@@ -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()

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,52 @@ 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 }
)
return {
category,
assets,
isLoading,
error
}
}
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},
{ immediate: true }
)
return {
category: computed(() => undefined),
assets: computed<AssetItem[]>(() => []),
isLoading: computed(() => false),
error: computed(() => null)
category,
assets,
isLoading,
error
}
}

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

@@ -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']
}
}