Compare commits

...

26 Commits

Author SHA1 Message Date
Glary-Bot
a612506a9e fix(remote): partition cache by api-key vs anonymous session
Pre-refactor getAuthScope() used four buckets including a literal 'apikey' / 'anon' split. The atomized scope dropped this distinction — both api-key and anonymous sessions ended up with {userId: null, workspaceId: null}, sharing a query key.

Because the QueryClient persists across api-key set/clear transitions (no eviction path runs without a router-driven WorkspaceAuthGate unmount), this caused cross-bucket cache reuse: an anonymous fetch could be served to an authenticated api-key reader within staleTime, or vice versa.

Add an opaque apiKeyBucket: 'apikey' | 'anon' | null field to RemoteAuthScope, include it in remoteOptionKeys.byRoute, and populate it from useApiKeyAuthStore().getApiKey() in both useRemoteOptions (Vue path) and useRemoteWidget (Litegraph path). Only the bucket literal lives in the query key — never the api-key value itself — so devtools/Sentry don't see secrets.

Adds a regression test asserting that anon and apikey scopes with identical userId/workspaceId produce distinct query keys.
2026-05-17 19:18:03 +00:00
Glary-Bot
d1652c2c5c fix(widgets): clear search box on item select via empty displayValue
reka-ui's ComboboxRoot defaults resetSearchTermOnBlur=true. After ComboboxItem.onSelect, the root closes and ~1ms later triggers ComboboxInput's resetSearchTerm, which without an explicit displayValue prop writes rootModelValue.toString() — i.e. the selected item's id — into the input. Our v-model on the input propagates that into ctx.searchQuery, so reopening the dropdown shows the id in the search box and a single-item filtered list.

Pass displayValue={() => ''} on ComboboxInput so reset always writes '' instead. Smallest surface, contract sits right next to the v-model it's correcting.
2026-05-17 16:29:07 +00:00
Glary-Bot
65b436daa9 fix(widgets): fall back to id when item name is empty string
Some remote assets (e.g. /proxy/seedance/assets) return name='' for items the user never titled. Trigger and list rows rendered blank because nullish coalescing (??) only catches null/undefined, not empty strings.

Add displayName(item) helper in base/remote/itemSchema.ts using logical-or fallback (matches the FormDropdownInput pattern in PR #11310) and use it in Trigger.vue's selected-label computed and Item.vue's name span, img alt, and video aria-label so the accessibility names also fall back instead of going empty.
2026-05-17 15:53:13 +00:00
Glary-Bot
0fe8cacf5e fix(widgets): render item previews in RemoteCombo dropdown
The atom family computed previewType but never used it: Item.vue's default slot only rendered the text. Wire previewType through RemoteComboContext, compute it in useRemoteCombo from item_schema.preview_type (default 'image'), and render <img> / <video preload=metadata muted playsinline> / audio toggle button based on the type when item.preview_url is set.

Audio toggle button uses @click.stop / @pointerdown.stop to prevent triggering item selection, and the existing widgets.remoteCombo.playAudioPreview / pauseAudioPreview i18n keys for the aria-label.

Drops the void previewType.value / void RemoteComboKey markers in RichComboWidget.vue (they were placeholders for this unfinished wiring) and the now-unused itemSchema computed.

Adds Item.preview.test.ts covering image/audio/no-preview branches.
2026-05-17 15:40:06 +00:00
Glary-Bot
2a70326336 fix(widgets): bind ComboboxInput v-model to searchQuery
Without v-model on ComboboxInput, the Root's controlled :search-term binding didn't receive keystrokes back through the input — typing in the search bar did nothing. Wire v-model directly to ctx.searchQuery.value so the input updates the shared search state.
2026-05-17 14:39:55 +00:00
Glary-Bot
67ca7ca3e1 fix(widgets): only show refresh button when remoteConfig is present
Previously showRefreshButton returned true whenever refresh_button wasn't explicitly false, including when remoteConfig itself was absent. Gate visibility on remoteConfig presence first so the refresh control only renders for combos that actually have a remote_combo config.
2026-05-17 08:13:07 +00:00
Glary-Bot
34dff7e369 fix(remote): scope auth header attachment to comfyApi client only
Only fetch and attach the platform auth header when descriptor.client === 'comfyApi'. The RemoteRequestClient union currently only contains 'comfyApi', but this guard prevents future additions from accidentally leaking platform credentials to external/non-platform routes.
2026-05-12 05:09:45 +00:00
Glary-Bot
531248d387 fix: address coderabbit review (7 findings)
- itemSchema: case-insensitive data:/blob: scheme check
- retry: don't retry ERR_CANCELED axios errors
- useRemoteOptions: return refetch promise instead of dropping it
- useRemoteWidget: recompute queryKey from current auth state per call
  (prevents stale cache partition across login/logout)
- useRemoteWidget: sanitize error log (avoid leaking axios request metadata)
- RichComboWidget.test: assert loading text + aria-disabled, not just trigger
- RemoteCombo.stories: drop redundant unused QueryClient/i18n decorator and
  the provide override that broke useI18n in child atoms

Nits:
- comboAdapter.test: drop unnecessary 'as never' casts (Partial<ComboInputSpec>
  is already correctly typed)
- useRemoteOptions.test: withSetup returns cleanup that unmounts the test app
2026-05-12 05:01:51 +00:00
Glary-Bot
eb8cec4d7a fix: address review feedback (preview URL normalization, disabled forwarding)
- mapToDropdownItem accepts an optional previewBaseUrl and resolves
  relative preview paths against it (preserves absolute / data: / blob:
  / protocol-relative URLs unchanged); useRemoteCombo passes the
  comfy-api base URL so previews render correctly when item_schema
  preview_url_field returns a relative path
- Forward widget.options.disabled through RichComboWidget into the
  RemoteCombo Root, Trigger, and Refresh atoms so a disabled remote
  combo is non-interactive (matches WidgetSelectDefault precedent)
- Tests for both fixes (preview URL normalization edge cases + disabled
  forwarding to trigger and refresh button)
2026-05-12 04:45:51 +00:00
Glary-Bot
d91f5da890 feat(widgets): atomize RichComboWidget + TanStack Query foundation
Implements the master plan from PR #11955 on top of PR #11310:

- Phase 1: Add @tanstack/vue-query, wire VueQueryPlugin in main.ts with
  bounded gcTime + retry policy. Module-level singleton via
  getAppQueryClient() so non-Vue contexts (legacy useRemoteWidget) can
  reuse the same cache.
- Phase 2: Move pure helpers to base/remote/ — itemSchema.ts (getByPath,
  resolveLabel, mapToDropdownItem, extractItems, buildSearchText),
  retry.ts (getBackoff, isRetriableError), diagnostics.ts (summarizeError,
  summarizePayload). Delete fetchRemoteRoute (auth headers now injected
  inline in useRemoteOptions per existing API-client pattern).
- Phase 3: platform/remote/composables/useRemoteOptions.ts wraps
  TanStack Query with a typed RequestDescriptor and a key factory keyed
  by client/route/params/{userId, workspaceId} for defense-in-depth
  partitioning (auth-teardown invariant covers the cache lifecycle).
- Phase 4: RemoteCombo/ atom family (Root/Trigger/Content/Search/List/
  Item/Empty/Loading/Error/Refresh/LayoutSwitcher) over reka-ui's
  Combobox primitives. CVA variants in remoteCombo.variants.ts mirror
  Button.vue conventions (size/variant/border axes). Reka data-attr
  styling for hover/highlighted/checked. Adapter pattern for spec→prop
  extraction (specAdapter.ts + comboAdapter.ts).
- Phase 5: useRemoteCombo (view layer: schema mapping, search index,
  auto_select). useRemoteWidget rewritten on getAppQueryClient() —
  preserves the IWidget mutation contract: first-load defaulting,
  control_after_refresh override, execution_success auto-refresh toggle.
- Phase 6: zComboInputOptionsValidated enforces remote XOR remote_combo
  to match backend XOR validation.
- Phase 7: Tests for new modules (comboAdapter, useRemoteOptions key
  factory, RichComboWidget atom-level flows, fast-check property test
  on mapToDropdownItem, XOR schema validation). Pure-helper tests
  relocated to base/remote/.
- Phase 8: A11y minimums on every atom (aria-label/aria-live/aria-busy/
  aria-disabled/aria-pressed; sr-only error/empty announcements).
- Phase 9: Storybook stories (Default / Loading / Error / Empty /
  WithSelection / KeyboardA11y) for the atom family. Token-aligned to
  the design system per master plan §11.2.b.

Removed:
- src/renderer/extensions/vueNodes/widgets/utils/fetchRemoteRoute.{ts,test.ts}
- src/renderer/extensions/vueNodes/widgets/utils/itemSchemaUtils.{ts,test.ts}
- src/renderer/extensions/vueNodes/widgets/utils/richComboHelpers.{ts,test.ts}
- The auth-scoped Cache API persistence layer in RichComboWidget.vue
- The legacy in-memory cacheEntry map in useRemoteWidget.ts

Quality gates: pnpm lint, typecheck, knip, build, test:unit (754 files,
10053 tests) all pass.
2026-05-06 07:12:10 +00:00
Alexander Piskun
09942a5b7f Merge branch 'main' into feat/RemoteComboOptions 2026-05-04 19:15:08 +03:00
Christian Byrne
560e53c68f fix: remove coming soon badge from parallel job execution (#11819)
*PR Created by the Glary-Bot Agent*

---

Removes the "coming soon" badge from the Parallel Job Execution feature
card on the cloud pricing page (`comfy.org/cloud/pricing`).

## Changes

- `apps/website/src/components/pricing/WhatsIncludedSection.vue`: drop
`isComingSoon: true` from feature11 so it renders with the standard
check icon and no badge.

The `isComingSoon` mechanism (clock icon + yellow badge) is preserved in
the component for future use on other features.

## Note

The FAQ copy elsewhere on the site (`cloud.faq.9.a`) still references
"one active job at a time" and "parallel runs soon". That copy will be
updated separately.

## Verification

- `pnpm typecheck` (website): 0 errors
- `pnpm lint`: clean (1 pre-existing warning unrelated to this change)
- `pnpm format:check`: clean
- `pnpm test:unit` (website): 20 passed
- Visual check via Playwright on local dev server (see screenshot)

## Screenshots

![Pricing page after change: Parallel job execution row shows green
check icon and no coming soon
badge](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/54c41067c2ba0bce5de11dd3b919e3c370be4eba2fd44eb3c411921f34bc088e/pr-images/1777688853166-87c5c07e-e4ad-4ef3-a892-f3e01e2f980f.png)

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11819-fix-remove-coming-soon-badge-from-parallel-job-execution-3546d73d365081d19060f976095d03ac)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:27:31 +00:00
Yourz
1999b7fba0 fix: remove (beta) from cloud.faq.3.a (#11905)
*PR Created by the Glary-Bot Agent*

---

## Summary

Remove `(beta)` from the `cloud.faq.3.a` translation entry in both
English and Simplified Chinese (`zh-CN`), since Comfy Cloud is no longer
in beta.

## Changes

`apps/website/src/i18n/translations.ts`:
- en: `Comfy Cloud (beta) has zero setup...` → `Comfy Cloud has zero
setup...`
- zh-CN: `Comfy Cloud(测试版)无需任何设置...` → `Comfy Cloud 无需任何设置...`

## Verification

- Pre-commit hooks (oxfmt, oxlint, eslint, typecheck, typecheck:website)
all passed
- Code review (oracle): 0 issues, ready to merge
- Manual verification via Playwright on `/cloud` and `/zh-CN/cloud` —
FAQ item 3 renders updated copy in both locales (screenshots attached)

## Screenshots

![English FAQ item 3 expanded — 'Comfy Cloud has zero setup...' (no
beta)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906512798-b5b8fc07-1ed1-43e2-88f5-35efd6ee7254.png)

![Simplified Chinese FAQ item 3 expanded — 'Comfy Cloud 无需任何设置...' (no
测试版)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/226e1a7ea5794b251aeaa587f0696b945f264afd4db5933eaa0125c5d12235ec/pr-images/1777906513275-1c0c0f6b-0408-4cc2-93e6-4a5e0d02a101.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11905-fix-remove-beta-from-cloud-faq-3-a-3566d73d36508150997bcf2c89826091)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 15:03:06 +00:00
Christian Byrne
285421a87c feat: add queue progress overlay feature survey (#11560)
*PR Created by the Glary-Bot Agent*

---

## Summary

Registers a new nightly feature survey for the Queue Progress Overlay
using the existing feature-survey registry (same pattern as the merged
node-search survey, PRs #8175/#8355/#9934).

- New registry entry `queue-progress-overlay` → Typeform `HZ5saxry`,
threshold **16**, 5s display delay.
- `trackFeatureUsed()` wired at the major user-initiated handlers inside
the overlay so the survey triggers regardless of panel location
(floating-right v1 or docked-left v2).
- Run button and other ActionBar items that the overlay pops over from
are deliberately **not** tracked — tracking is scoped to interactions
that originate inside the job panel / queue progress overlay itself.

## Tracked interactions

Both variants share most sub-components, so tracking is instrumented
once at each logical surface:

- **`QueueProgressOverlay.vue`** (v1 container): `viewAllJobs`,
`interruptAll`, `cancelQueuedWorkflows`, `onClearHistoryFromMenu`,
`toggleAssetsSidebar`, `onCancelItem`, `onDeleteItem`, `inspectJobAsset`
- **`QueueOverlayExpanded.vue`**: job tab switches
- **`JobHistorySidebarTab.vue`** (v2 docked): job tab switches,
`clearQueuedWorkflows`, `onClearHistory`, `onCancelItem`,
`onDeleteItem`, `onViewItem`
- **`JobFilterActions.vue`** (shared): workflow filter + sort mode
selections
- **`JobHistoryActionsMenu.vue`** (shared): docked-history toggle +
run-progress-bar toggle

Deliberately **not tracked** to keep the signal clean:
- Hover handlers (ambient preview behaviour)
- Search-box keystrokes (debounced typing)
- Context menu open and menu-item dispatch — menu actions either bubble
through already-tracked terminal handlers (e.g. inspect-asset →
`onViewItem`) or are secondary operations (copy-id, open-workflow,
download). Avoids double-counting per code review feedback.

## How it works (inherits from existing infrastructure)

1. `surveyRegistry.ts` drives `NightlySurveyController` →
`NightlySurveyPopover`, which handles the Typeform embed.
2. Eligibility already gated on `isNightly && !isCloud && !isDesktop`,
once-per-user, 4-day global cooldown across all surveys, and opt-out.
3. Typeform response routing to #C0ALLT6Q3SQ is handled on the Typeform
side.

## Verification

- `pnpm typecheck` 
- `pnpm lint`  (no new warnings)
- `pnpm knip` 
- `pnpm test:unit` on `src/components/queue`,
`src/components/sidebar/tabs/JobHistorySidebarTab`,
`src/platform/surveys` → **123/123 passing**
- Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck) all
pass
- Manual: dev server + backend boot cleanly, app loads without new
runtime errors, `localStorage['Comfy.FeatureUsage']` layout verified to
match what `useFeatureUsageTracker` writes

## Notes

- Survey key `queue-progress-overlay` covers both v1 (floating-right)
and v2 (docked-sidebar) per product guidance: _"This should trigger
regardless of the location of the panel (docked from left or floating on
right)."_ Both surfaces are the same product feature — the survey is
intentionally scoped to the whole job-panel experience.


## Screenshots

![App loads cleanly with the new survey code in place — empty canvas
with Run button and sidebar, no runtime
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/fd18977704544ba278ad3fa42c695289ae7e02001550ce38955d6fb47d872146/pr-images/1776914667332-03e4ef0a-4137-47c6-87b8-b554770b8900.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11560-feat-add-queue-progress-overlay-feature-survey-34b6d73d3650819a9a50fd67fd9b5941)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:50:18 +00:00
Christian Byrne
5523df1aea fix(website): unstretch See all case studies button (#11854)
*PR Created by the Glary-Bot Agent*

---

## Summary

The "See all case studies" button on the homepage
`CaseStudySpotlightSection` was rendering oddly stretched because it had
`class="flex-1 text-center"` while being the sole child of a `flex-row`
container — it expanded to fill the entire content column (~592px)
instead of sizing to its label.

This drops `flex-1`/`text-center` and adds `items-start` to the wrapper
so the button sizes to its content and is left-aligned, matching the
proportions of every other outline `BrandButton` on the site (Hero,
UseCase, customer detail, etc.).

## Changes

- `apps/website/src/components/home/CaseStudySpotlightSection.vue`:
remove `flex-1 text-center` from the `BrandButton` and align the row's
items to the start.

`BrandButton` already centers its label internally via `inline-flex …
justify-center`, so dropping `text-center` is a no-op visually.

## Before / After

- Desktop before: button width = 592px (stretched across the column)
- Desktop after: button width = 223px (natural)
- Mobile: 1-column layout, now consistently left-aligned

## Review Focus

Whether the fix should also live on the `BrandButton` component itself
(e.g. a global `max-width`) instead of at the call site. I went with the
instance-level fix because every other CTA in the website intentionally
uses bare `BrandButton` and lets the content size it; only this one had
`flex-1`. A blanket `max-width` would risk changing Hero/MobileMenu
buttons that explicitly opt into `w-full lg:w-auto lg:min-w-60`.

## Screenshots

![Before: button stretched across the full content
column](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244354-4dd9af45-2458-4d8a-a1a7-1f6b88b6fc4b.png)

![After: button sized to content,
left-aligned](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774244808-5bab2801-0140-4b4a-9d9e-61a467090de3.png)

![After: mobile view, left-aligned natural
width](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/19522cd256addec524dfcc25228a9ad732d07646330472c58513d6b4714808ca/pr-images/1777774245316-1ca9609d-3de0-4c85-973e-a87e296fa65f.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11854-fix-website-unstretch-See-all-case-studies-button-3556d73d365081abb3bbe9dbc51cbc07)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 14:03:39 +00:00
Christian Byrne
65876c635d feat(website): add responsive media tooling for marketing assets (#11869)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds the building blocks for a responsive media system on
`apps/website`, motivated by the gallery video blurriness raised in
Slack. Three independent pieces:

1. **`<SiteVideo>` Vue component + URL helper** — emits a `<video>` with
multiple `<source>` tags, designed to pair with assets named
`${name}-${width}.${format}` on `media.comfy.org`.
2. **`scripts/process-videos.sh`** — local-developer `ffmpeg` helper
that produces VP9/WebM + H.264/MP4 variants and a poster JPG. Not wired
into CI; the team uploads to `media.comfy.org` out-of-band.
3. **Marketing image conventions** — shared `MARKETING_FORMATS` /
`MARKETING_WIDTHS` constants and a README documenting how to render
local marketing images via Astro's built-in `<Picture>` from
`astro:assets`.

This PR is **infrastructure only** — no existing pages are modified.
Adoption (e.g. converting `HeroSection`, gallery videos) is a follow-up.
The new files are added to knip's ignore list with the existing "pending
stacked PR" pattern.

## Why this shape

- **No custom `<Picture>` wrapper.** Astro 5 already ships a
`ResponsiveImage` component (name conflict), and Astro's
`LocalImageProps | RemoteImageProps` discriminated union does not
survive a thin wrapper without unsafe `as` casts. Shared constants give
the consistency benefit at lower cost.
- **No CI media-upload step.** The `Release: Website` workflow currently
only refreshes the Ashby snapshot; wiring GCS uploads into it would
require new secrets and team coordination beyond this PR's scope. The
script runs locally and outputs are uploaded to `media.comfy.org` the
same way as today.
- **Single resolution per `<video>`.** `<source media="...">` inside
`<video>` is unreliable across browsers (Safari ignores it). The script
generates multiple widths so callers can pick one per page; JS-based
selection can be layered on later if metrics demand it.

## What's verified

- `pnpm --filter @comfyorg/website test:unit` — 30 pass (7 new for
`buildVideoSources` / `videoKey`)
- `pnpm --filter @comfyorg/website typecheck` — clean
- `pnpm --filter @comfyorg/website build` — 41 pages built clean
- `pnpm knip` — exit 0
- `oxfmt --check` and `oxlint` clean on all changed files
- `bash -n` on `process-videos.sh` clean; usage and missing-deps paths
exercised manually
- Manual: home page and `/gallery` rendered via `astro dev` — both
unchanged with zero console errors (screenshots attached)

## Review feedback addressed

After Oracle review, three follow-up commits land:

- **`SiteVideo` reactivity** — `sources` is now `computed`; the
`<video>` is keyed on the joined source URLs so it remounts when the
source set changes (browsers don't reload on `<source>` mutation).
- **`SiteVideo` accessibility** — `aria-hidden="true"` only when truly
decorative (no `alt` and no `controls`).
- **Shell script robustness** — probes duration with `ffprobe` and falls
back to `t=0` for clips shorter than 1s; enables `nocaseglob` so
`CLIP.MP4` is picked up.
- **Docs** — clarifies when to use `<SiteVideo>` (lightweight
multi-source) vs `<VideoPlayer>` (captions, controls, scrubber).

## Out of scope (follow-ups)

- Converting existing pages (`HeroSection`, customer detail heros,
gallery) to use the new components. Most current images are CDN-hosted
and migrating them is a separate decision.
- Re-encoding the gallery videos at a higher source width to actually
fix the blurriness — that requires the team to run `process-videos.sh`
against the source clips and re-upload.
- Combining `<SiteVideo>`'s multi-source support with `<VideoPlayer>`'s
rich chrome.

## Screenshots

![Home page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791647863-fb1ea2bf-32fc-40d9-852d-cceb3bc148f7.png)

![Gallery page renders unchanged with no console
errors](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/df0d9bade4eca96daf49f97a3e6864cc74345f430e4a9308e2e68d635dfd8e04/pr-images/1777791648186-0b598260-a836-4866-9c55-9d0e99de6d4c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11869-feat-website-add-responsive-media-tooling-for-marketing-assets-3556d73d3650818899c7f9ed3204c9a5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-04 13:25:20 +00:00
bigcat88
28c97d3687 fix: use "item.id" if both label and title are missing. 2026-05-04 15:18:21 +03:00
jaeone94
04918360eb Use hash lookup for missing asset detection (#11873)
## Summary

Use exact BLAKE3 hash lookups first for missing model/media detection,
and add a separate public-inclusive input asset cache so public input
assets are considered missing-detection candidates without changing the
user-only input assets shown in the UI.

## Changes

- **What**:
- Added `assetService.checkAssetHash()` for `HEAD
/api/assets/hash/{hash}` status-only existence checks.
- Added strict BLAKE3 hash helpers so only `blake3:<64 hex>` media
values and raw 64-hex BLAKE3 model metadata are sent to the hash
endpoint.
- Updated missing media detection to group BLAKE3 candidates by hash,
resolve them through the hash endpoint, and fall back to the legacy
asset list path for invalid/unverifiable/non-hash values.
- Updated missing model detection to use hash lookup for BLAKE3-backed
asset-supported candidates before falling back to the existing node-type
asset matching path.
- Added `assetService.getInputAssetsIncludingPublic()` backed by a
dedicated cache that fetches input assets with `include_public=true` for
missing media fallback checks.
- Kept `assetsStore.inputAssets` user-only for widget/UI display, while
invalidating the public-inclusive missing-detection cache when input
assets may change.
- Added abort handling for paginated asset fetches and shared
public-input cache callers so one aborted caller does not cancel the
shared fetch for other callers.
- Added regression coverage for hash lookup, fallback behavior, abort
paths, public input fallback detection, and cache invalidation.
- **Dependencies**: None.
- **Change size**:
  - Production code: 4 files, 400 insertions, 24 deletions, net +376.
  - Test code: 4 files, 806 insertions, 59 deletions, net +747.
  - Total: 8 files, 1206 insertions, 83 deletions, net +1123.

## Review Focus

- The public-inclusive input asset cache is intentionally separate from
`assetsStore.inputAssets`. The existing store data is user-only and
drives the asset widgets/sidebar, so using it for missing input
detection misses public assets. Making that store public-inclusive would
change UI data semantics; this PR instead keeps the UI dataset unchanged
and adds a missing-detection-specific cache in `assetService`.
- Hash lookup is only used when the workflow exposes a valid BLAKE3
hash. Filename-like values and invalid hash values still use the legacy
fallback path.
- Missing model detection keeps the existing fallback behavior for
non-hash candidates and for hash checks that are invalid or fail
transiently.
- Async model download cache refresh behavior is left unchanged; this PR
avoids coupling model download completion to input asset cache
invalidation.
- No browser/e2e test was added because this changes the missing asset
detection data path, not UI interaction or rendering. The behavioral
coverage is in unit tests for the asset service and the missing
media/model scanners.

## Follow-up Items

- Fix `assetsStore.updateAssetTags()` partial-failure recovery. If
`removeAssetTags()` succeeds and `addAssetTags()` fails, the local model
asset cache can roll back to tags that the backend has already removed;
this should be handled in a focused model asset cache PR.
- Consider extracting shared hash-verification flow used by missing
media and missing model scans after this behavior stabilizes.
- Consider adding a concurrency cap or short-lived request cache for
large workflows with many unique hash lookups.
- Consider splitting `assetService.ts` further, e.g. hash helpers, abort
utilities, and the public-inclusive input asset cache.
- Consider tightening the asset hash service API shape so callers do not
directly depend on HTTP-oriented statuses such as `invalid`.
- Consider adding broader mutation-path coverage for public-inclusive
input cache invalidation once the cache has more consumers.

Linear: FE-534

## Screenshots (if applicable)

Before <false positive / missing image / public asset>


https://github.com/user-attachments/assets/db7ce2a9-b169-4fae-bf9f-98bb93d3ee6d

After 


https://github.com/user-attachments/assets/29af9f9e-b536-4fcd-a426-3add40bcb165



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11873-Use-hash-lookup-for-missing-asset-detection-3556d73d36508165babafb16614be0d8)
by [Unito](https://www.unito.io)
2026-05-04 03:59:54 +00:00
bigcat88
97c2a0d364 feat(widgets): rich combo widget for remote options with previews
Adds a Vue-native renderer for combo inputs that declare `remote_combo=`
(RemoteComboOptions on the backend). Wired through WidgetSelect; runs in
parallel to the existing useRemoteWidget composable, which continues to
handle plain `remote=` combos.

The widget fetches a single items array from a relative `/proxy/...`
route — the frontend always prepends the comfy-api base URL and injects
auth headers (no opt-out flag while the feature is partner-node-only).
Items are mapped via the per-node `item_schema`, with image/video/audio
previews, search across multiple fields, optional auto-select first/last,
and a refresh button.

Caching: browser Cache API with TTL from `refresh`, partitioned by full
auth scope (workspace / firebase uid / api-key / anon). Refresh button
sequences cache delete before refetch to avoid the fast-response race.
Logging: auth headers and response bodies are redacted from error logs.

Also adds an audio preview branch to FormDropdownMenuItem — used by the
new widget when `preview_type='audio'`.

Tests cover: single-shot fetch, error classification, retry exhaustion,
refresh, deselect, stale-id preservation, cache-key partitioning,
route resolution, item-schema mapping, and Zod relative-route
validation.
2026-05-03 14:15:32 +03:00
Dante
af70d88860 fix: keep finished badge fully opaque in ProgressToastItem (#11542)
## Summary
- fix
**[slack](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776801170742579)**
- Move `opacity-50` off the row container onto the asset-name column
only, so the contrast badge (white pill, dark label) is not dimmed to
gray-on-gray when a download completes.
- Matches the Figma intent that the `FINISHED` badge stands out —
designer spec uses `base/foreground` for pill, `base/background` for
text, which is unreadable when the parent is 50% opacity.

<img width="560" height="269" alt="Screenshot 2026-04-23 at 2 46 17 PM"
src="https://github.com/user-attachments/assets/fb84aa57-c348-4a86-9a65-9342c12400e1"
/>
<img width="764" height="332" alt="Screenshot 2026-04-23 at 2 46 41 PM"
src="https://github.com/user-attachments/assets/ecbe6a5f-c2e8-4427-9c1d-f8f123009d2e"
/>


## Before / After

![before /
after](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/jaewon/fe-237-fix-honeytoast-badge-finished-opacity/.github/pr-images/fe-237-before-after.png)

## Repro
Cloud → trigger a model download → wait for completion → the `FINISHED`
badge is the same tone as the toast surface (see Slack thread
screenshots).

## Test plan
- [ ] Complete a model download in cloud and confirm the `FINISHED`
badge is clearly legible in both themes.
- [ ] File name + subtitle still appear dimmed to signal the row is
completed.
- [ ] Running / failed / pending states unchanged.

- Fixes
[FE-237](https://linear.app/comfyorg/issue/FE-237/fix-honeytoast-badge-text-color-for-finished-job-matches-background)
2026-05-03 08:40:27 +00:00
Christian Byrne
c955309b26 [chore] Update Comfy Registry API types from comfy-api@911406c (#11518)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 911406c
- Generated on: 2026-04-17T16:10:40Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11518-chore-Update-Comfy-Registry-API-types-from-comfy-api-911406c-3496d73d36508146a1e2e1ee90640fa4)
by [Unito](https://www.unito.io)

Co-authored-by: coderfromthenorth93 <213232275+coderfromthenorth93@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-03 01:01:41 -07:00
Comfy Org PR Bot
7abd9d12c8 chore(website): refresh Ashby roles snapshot (#11851)
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.

**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.

The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.

Triggered by workflow run `25260868155`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11851-chore-website-refresh-Ashby-roles-snapshot-3546d73d365081579f98f13f7b58e611)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-02 14:15:34 -07:00
Yourz
dd9cb42fa1 feat: add Run your first workflow button to home hero (#11848)
## Summary

Add an outline-style `BrandButton` to the right side of the home page
hero section linking to the workflows page.

## Changes

- **What**: 
- Added a `Run your first workflow` outline button below the subtitle in
`apps/website/src/components/home/HeroSection.vue`, linking to
`externalLinks.workflows`. Mirrors the button pattern from
`product/local/HeroSection.vue`.
- Added `hero.runFirstWorkflow` i18n entry (en + zh-CN) in
`apps/website/src/i18n/translations.ts`.

## Review Focus

- Confirmed alignment with design spec.

<img width="1505" height="776" alt="image"
src="https://github.com/user-attachments/assets/215e667d-1827-447b-99b8-eba8cb5ec7e3"
/>
<img width="335" height="700" alt="image"
src="https://github.com/user-attachments/assets/aeac0876-74c3-4e12-a4b3-203f9e541bc2"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11848-feat-add-Run-your-first-workflow-button-to-home-hero-3546d73d365081358d54eddfda71111e)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-05-02 20:18:28 +00:00
pythongosssss
ccd19d8695 test: add metadata parser coverage (#11307)
## Summary
Adds tests for metadata parsers

## Changes

- **What**: 
- add test file generation script
- identified & fixed bug in webp exif parsing over-reading
- identified & fix bug in mp3/ogg parser where it would read from a
fixed position instead of relative, causing incorrect reads throwing
RangeError
- added catch in latent + json parsing to resolve errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11307-test-add-metadata-parser-coverage-3446d73d36508108ac36dddcec0a54d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-05-02 13:18:45 -07:00
Yourz
809fba7b36 feat: add Groove Jones customer story (#11849)
## Summary

Add a new customer story for Groove Jones — Crocs x NFL FOOH holiday
campaign for Dick's Sporting Goods, delivered with Comfy.

## Changes

- **What**:
- New entry in
[`apps/website/src/config/customerStories.ts`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/feat/groove-jones-customer-story/apps/website/src/config/customerStories.ts)
registering slug `groove-jones` with cover image hosted on
`media.comfy.org/website/customers/groove-jones/`.
- Added `customers.story.groove-jones.{category,title,body}` and
`customers.detail.groove-jones.topic-1` … `topic-10` translations (en +
zh-CN) in
[`apps/website/src/i18n/translations.ts`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/feat/groove-jones-customer-story/apps/website/src/i18n/translations.ts).
10 sections matching design sidebar: INTRO, THE OUTPUT, THE PROBLEM, HOW
COMFY SOLVED THE PROBLEM, BRAND-TRAINED LORAS, MULTI-MODEL
ORCHESTRATION, THE PIPELINE, VERSION CONTROL, FINISHING IN NUKE, THE
TAKEAWAY.
- Includes 2 pull quotes (Doug Hogan, Dale Carman), 1 final blockquote +
author card, and 3 inline images.
- Routes `/customers/groove-jones` and `/zh-CN/customers/groove-jones`
are auto-generated by `[slug].astro`.

## Review Focus

- Contributors author card uses `TBD` placeholder names/roles — to be
filled in.
- No `readMoreHref` set yet (no public blog post URL).
- All 4 images uploaded to
`gs://comfy-org-videos/website/customers/groove-jones/` and served via
`media.comfy.org`.

<img width="1000" height="535" alt="Kapture 2026-05-02 at 23 17 04"
src="https://github.com/user-attachments/assets/28654d24-0d49-4303-82ac-b6923cd6bc93"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11849-feat-add-Groove-Jones-customer-story-3546d73d36508128a64bd6809ad77447)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-05-02 18:31:18 +00:00
Christian Byrne
df2ae6f2d0 fix(load3d): dispose THREE.Points GPU resources in clearModel() (#11836)
Fixes #11345

## Summary

`clearModel()` in `SceneModelManager` only traversed and disposed
`THREE.Mesh` instances, leaving `THREE.Points` objects (created by
`handlePLYModeSwitch()` for point-cloud mode) leaking GPU geometry and
material memory on repeated point-cloud loads/clears.

## Changes

- `SceneModelManager.ts`: extend the dispose traversal in `clearModel()`
to also handle `THREE.Points`, mirroring the pattern already used by
`removeAllMainModelsFromScene()`.
- `SceneModelManager.test.ts`: add regression test verifying
`geometry.dispose()` and `material.dispose()` are called for
`THREE.Points` children on `clearModel()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11836-fix-load3d-dispose-THREE-Points-GPU-resources-in-clearModel-3546d73d365081718338e824bc3e737d)
by [Unito](https://www.unito.io)
2026-05-02 12:39:12 +00:00
114 changed files with 6623 additions and 1281 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -69,6 +69,50 @@ test.describe('Homepage @smoke', () => {
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()

View File

@@ -0,0 +1,83 @@
# Website Scripts
## `refresh-ashby-snapshot.ts`
Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
## `process-videos.sh`
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.
```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```
### Output
For each source video at `./video-sources/foo.mp4`, you get:
```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```
The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.
### Pairing with `<SiteVideo>`
Once the assets are uploaded, render them with:
```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```
### `<SiteVideo>` vs `<VideoPlayer>`
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.
If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.
### Encoder choices
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.
### Why a single resolution per video
`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
set -euo pipefail
if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi
input_dir=$1
output_dir=$2
widths=${3:-1280}
for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done
if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi
mkdir -p "$output_dir"
shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob
if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi
for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"
for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"
echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done
poster_out="$output_dir/${name}-poster.jpg"
duration_raw=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
duration="$duration_raw"
else
duration=0
fi
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
poster_seek=1
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done
echo "done. upload contents of $output_dir to media.comfy.org."

View File

@@ -0,0 +1,51 @@
# Marketing Assets
Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
## Usage
Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:
```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.
A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.
## When to use this vs. `media.comfy.org`
- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.
## Source image guidelines
- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'
const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()
const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>
<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>

View File

@@ -35,7 +35,10 @@ const routes = getRoutes(locale)
</div>
<!-- Right: content -->
<div class="flex flex-col justify-between p-6 lg:flex-1">
<div
data-testid="case-study-content"
class="flex flex-col justify-between p-6 lg:flex-1"
>
<div class="flex flex-col gap-8">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
@@ -52,12 +55,8 @@ const routes = getRoutes(locale)
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
:href="routes.customers"
variant="outline"
class="flex-1 text-center"
>
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
<BrandButton :href="routes.customers" variant="outline">
{{ t('caseStudy.seeAll', locale) }}
</BrandButton>
</div>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
@@ -32,6 +34,15 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
>
{{ t('hero.runFirstWorkflow', locale) }}
</BrandButton>
</div>
</section>
</template>

View File

@@ -101,17 +101,9 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<span
v-if="feature.isComingSoon"
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
>
{{ t('pricing.included.comingSoon', locale) }}
</span>
</div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->

View File

@@ -52,6 +52,15 @@ export const customerStories: CustomerStory[] = [
detailPrefix: 'customers.detail.ubisoft-chord',
readMoreHref:
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
},
{
slug: 'groove-jones',
image:
'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp',
category: 'customers.story.groove-jones.category',
title: 'customers.story.groove-jones.title',
body: 'customers.story.groove-jones.body',
detailPrefix: 'customers.detail.groove-jones'
}
]

View File

@@ -1,24 +1,10 @@
{
"fetchedAt": "2026-04-24T18:59:03.989Z",
"fetchedAt": "2026-05-02T20:15:18.321Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "e915f2c78b17f93b",
"title": "Senior Product Designer",
@@ -33,13 +19,6 @@
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "547b6ba622c800a5",
"title": "Senior Product Designer - Craft",
@@ -115,6 +94,13 @@
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
},
{
"id": "2eb53e8943cc9396",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
}
]
},
@@ -122,6 +108,27 @@
"name": "MARKETING",
"key": "marketing",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
@@ -144,7 +151,7 @@
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Senior Technical Recruiter",
"title": "Talent Lead",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"

View File

@@ -11,6 +11,10 @@ const translations = {
'zh-CN':
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
},
'hero.runFirstWorkflow': {
en: 'Run your first workflow',
'zh-CN': '运行你的第一个工作流'
},
// ProductShowcaseSection
'showcase.subtitle1': {
@@ -910,9 +914,9 @@ const translations = {
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1276,10 +1280,6 @@ const translations = {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
'pricing.included.comingSoon': {
en: 'coming soon',
'zh-CN': '即将推出'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },
@@ -2243,6 +2243,20 @@ const translations = {
'zh-CN':
'育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。'
},
'customers.story.groove-jones.category': {
en: 'CASE STUDY',
'zh-CN': '案例研究'
},
'customers.story.groove-jones.title': {
en: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy",
'zh-CN':
"Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销"
},
'customers.story.groove-jones.body': {
en: 'Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline.',
'zh-CN':
'达拉斯创意工作室 Groove Jones 借助 Comfy在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。'
},
'customers.story.readMore': {
en: 'READ MORE ON THIS TOPIC',
'zh-CN': '阅读更多相关内容'
@@ -3276,6 +3290,227 @@ const translations = {
'zh-CN': 'ComfyUI 博客'
},
// Customer Detail: Groove Jones
// Topic 1: Intro
'customers.detail.groove-jones.topic-1.label': {
en: 'INTRO',
'zh-CN': '简介'
},
'customers.detail.groove-jones.topic-1.block.0': {
en: 'Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick\u2019s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.',
'zh-CN':
'位于达拉斯的创意工作室 Groove Jones为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick\u2019s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL他们反复遇到同一个挑战用商业项目的工期和预算交付电影级的 VFX 质量。'
},
'customers.detail.groove-jones.topic-1.block.1': {
en: 'For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick\u2019s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.',
'zh-CN':
'在 Crocs x NFL 联名系列的节日上市项目中这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick\u2019s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。'
},
// Topic 2: The Output
'customers.detail.groove-jones.topic-2.label': {
en: 'THE OUTPUT',
'zh-CN': '交付成果'
},
'customers.detail.groove-jones.topic-2.title': {
en: 'The Output Groove Jones Achieved Using Comfy',
'zh-CN': 'Groove Jones 借助 Comfy 实现的交付成果'
},
'customers.detail.groove-jones.topic-2.block.0': {
en: 'A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline\nHyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick\u2019s Sporting Goods parking lots\nVertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts\nSame-day iteration on client notes instead of week-long asset updates\nWinner, Aaron Awards 2024: Best AI Workflow for Production',
'zh-CN':
'在紧迫的节日档期内交付完整的 FOOH虚构户外广告社媒营销活动\n超写实视频巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick\u2019s Sporting Goods 停车场\n面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物\n客户反馈当天迭代不再需要数周的资产更新周期\n荣获 2024 年 Aaron Awards最佳 AI 制作工作流奖'
},
// Topic 3: The Problem
'customers.detail.groove-jones.topic-3.label': {
en: 'THE PROBLEM',
'zh-CN': '挑战'
},
'customers.detail.groove-jones.topic-3.title': {
en: 'The Problem Groove Jones Was Trying to Solve',
'zh-CN': 'Groove Jones 试图解决的问题'
},
'customers.detail.groove-jones.topic-3.block.0': {
en: 'A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team\u2019s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.',
'zh-CN':
'按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作每支球队鞋款的高精建模、look development、灯光、渲染、合成客户每次想要新变体都要重新渲染。这也意味着庞大的团队建模师、纹理师、灯光师、合成师以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。'
},
// Topic 4: How Comfy Solved the Problem
'customers.detail.groove-jones.topic-4.label': {
en: 'HOW COMFY SOLVED THE PROBLEM',
'zh-CN': 'Comfy 如何解决问题'
},
'customers.detail.groove-jones.topic-4.title': {
en: 'How Groove Jones Used Comfy to Solve the Problem',
'zh-CN': 'Groove Jones 如何用 Comfy 解决问题'
},
'customers.detail.groove-jones.topic-4.block.0': {
en: 'Groove Jones\u2019s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy\u2019s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.',
'zh-CN':
'Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。'
},
'customers.detail.groove-jones.topic-4.block.1.text': {
en: 'Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.',
'zh-CN':
'Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。'
},
'customers.detail.groove-jones.topic-4.block.1.name': {
en: 'Doug Hogan | Senior Creative Technologist @ Groove Jones',
'zh-CN': 'Doug Hogan | Groove Jones 高级创意技术总监'
},
// Topic 5: Brand-Trained LoRAs
'customers.detail.groove-jones.topic-5.label': {
en: 'BRAND-TRAINED LORAS',
'zh-CN': '品牌定制 LORA'
},
'customers.detail.groove-jones.topic-5.title': {
en: 'Brand-Trained LoRAs for Hero Assets',
'zh-CN': '为主视觉资产定制的品牌 LoRA'
},
'customers.detail.groove-jones.topic-5.block.0': {
en: 'Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick\u2019s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.',
'zh-CN':
'Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick\u2019s Sporting Goods 门店外景训练了定制 LoRA让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致不需要逐帧修正——而这通常意味着数周的 look development 工作量。'
},
'customers.detail.groove-jones.topic-5.block.1.src': {
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp',
'zh-CN':
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp'
},
'customers.detail.groove-jones.topic-5.block.1.alt': {
en: 'Grid of brand-accurate NFL team Crocs generated via custom LoRAs',
'zh-CN': '通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格'
},
'customers.detail.groove-jones.topic-5.block.1.caption': {
en: 'Brand-accurate NFL team colorways generated through custom LoRAs.',
'zh-CN': '通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。'
},
// Topic 6: Multi-Model Orchestration
'customers.detail.groove-jones.topic-6.label': {
en: 'MULTI-MODEL ORCHESTRATION',
'zh-CN': '多模型编排'
},
'customers.detail.groove-jones.topic-6.title': {
en: 'Multi-Model Orchestration in a Single Graph',
'zh-CN': '单张图内的多模型编排'
},
'customers.detail.groove-jones.topic-6.block.0': {
en: 'The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley\u2019s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.',
'zh-CN':
'这个创意在不同阶段需要不同的生成模型Flux 用于关键帧静帧开发Gemini Flash 2.5Nano Banana用于快速构思和变体生成Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。'
},
'customers.detail.groove-jones.topic-6.block.1.text': {
en: 'The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.',
'zh-CN':
'Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。'
},
'customers.detail.groove-jones.topic-6.block.1.name': {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
// Topic 7: The Pipeline
'customers.detail.groove-jones.topic-7.label': {
en: 'THE PIPELINE',
'zh-CN': '流水线'
},
'customers.detail.groove-jones.topic-7.title': {
en: 'Storyboards to Previz to Final Shot in One Pipeline',
'zh-CN': '从故事板到 Previz 再到成片,全部在一条流水线内'
},
'customers.detail.groove-jones.topic-7.block.0': {
en: 'The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.',
'zh-CN':
'工作流从传统故事板开始用于叙事确认,再进入 CGI blocking锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。'
},
'customers.detail.groove-jones.topic-7.block.1.src': {
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp',
'zh-CN':
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp'
},
'customers.detail.groove-jones.topic-7.block.1.alt': {
en: 'Storyboard grid for the Crocs x NFL holiday campaign',
'zh-CN': 'Crocs x NFL 节日营销的故事板网格'
},
'customers.detail.groove-jones.topic-7.block.1.caption': {
en: 'Grayscale storyboards used to lock narrative beats before generation.',
'zh-CN': '在生成之前用于锁定叙事节奏的灰度故事板。'
},
'customers.detail.groove-jones.topic-7.block.2.src': {
en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp',
'zh-CN':
'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp'
},
'customers.detail.groove-jones.topic-7.block.2.alt': {
en: 'Composition progression from blocking to mid-render to final shot',
'zh-CN': '从 blocking 到中间渲染再到最终镜头的构图演进'
},
'customers.detail.groove-jones.topic-7.block.2.caption': {
en: 'Composition progression: wireframe blocking, mid-render, and final shot.',
'zh-CN': '构图演进:线框 blocking、中间渲染、最终成片。'
},
// Topic 8: Version Control
'customers.detail.groove-jones.topic-8.label': {
en: 'VERSION CONTROL',
'zh-CN': '版本管理'
},
'customers.detail.groove-jones.topic-8.title': {
en: 'Workflow Files as Version Control',
'zh-CN': '把工作流文件当作版本管理'
},
'customers.detail.groove-jones.topic-8.block.0': {
en: 'Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.',
'zh-CN':
'每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。'
},
// Topic 9: Finishing in Nuke
'customers.detail.groove-jones.topic-9.label': {
en: 'FINISHING IN NUKE',
'zh-CN': 'Nuke 终修'
},
'customers.detail.groove-jones.topic-9.title': {
en: 'Finishing in Nuke',
'zh-CN': '在 Nuke 中完成终修'
},
'customers.detail.groove-jones.topic-9.block.0': {
en: 'Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.',
'zh-CN':
'生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。'
},
// Topic 10: The Takeaway
'customers.detail.groove-jones.topic-10.label': {
en: 'THE TAKEAWAY',
'zh-CN': '总结'
},
'customers.detail.groove-jones.topic-10.title': {
en: 'Conclusion',
'zh-CN': '结语'
},
'customers.detail.groove-jones.topic-10.block.0': {
en: 'By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.',
'zh-CN':
'通过在 Comfy 中搭建整套 FOOH 流水线Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。'
},
'customers.detail.groove-jones.topic-10.block.1.text': {
en: 'At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.',
'zh-CN':
'在 Groove Jones我们非常在意交付让人说"WOW"的作品但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃Comfy 帮我们彻底解决了这个问题。'
},
'customers.detail.groove-jones.topic-10.block.1.name': {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
'customers.detail.groove-jones.topic-10.block.2.label': {
en: 'GROOVE JONES CONTRIBUTORS',
'zh-CN': 'GROOVE JONES 贡献者'
},
'customers.detail.groove-jones.topic-10.block.2.name': {
en: 'TBD',
'zh-CN': '待补充'
},
'customers.detail.groove-jones.topic-10.block.2.role': {
en: 'TBD',
'zh-CN': '待补充'
},
// Contact FormSection
'contact.form.badge': {
en: 'CONTACT SALES',

View File

@@ -0,0 +1,3 @@
export const MARKETING_FORMATS = ['avif', 'webp'] as const
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { buildVideoSources, videoKey } from './video'
describe('buildVideoSources', () => {
it('builds a source per requested format', () => {
const sources = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/website/marketing',
width: 1280,
formats: ['webm', 'mp4']
})
expect(sources).toEqual([
{
src: 'https://media.comfy.org/website/marketing/hero-1280.webm',
type: 'video/webm',
format: 'webm'
},
{
src: 'https://media.comfy.org/website/marketing/hero-1280.mp4',
type: 'video/mp4',
format: 'mp4'
}
])
})
it('preserves caller-supplied format order', () => {
const sources = buildVideoSources({
name: 'clip',
baseUrl: 'https://cdn.example.com/v',
width: 960,
formats: ['mp4', 'webm']
})
expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm'])
})
it('strips a single trailing slash from baseUrl', () => {
const sources = buildVideoSources({
name: 'reel',
baseUrl: 'https://media.comfy.org/website/marketing/',
width: 1920,
formats: ['webm']
})
expect(sources[0]?.src).toBe(
'https://media.comfy.org/website/marketing/reel-1920.webm'
)
})
it('returns an empty list when no formats are requested', () => {
const sources = buildVideoSources({
name: 'x',
baseUrl: 'https://example.com',
width: 640,
formats: []
})
expect(sources).toEqual([])
})
})
describe('videoKey', () => {
it('changes when the source URL list changes', () => {
const at1280 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const at640 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 640,
formats: ['webm', 'mp4']
})
expect(videoKey(at1280)).not.toBe(videoKey(at640))
})
it('is stable across repeated calls with the same inputs', () => {
const args = {
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4'] as const
}
expect(
videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))
).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] })))
})
it('reflects format-order changes', () => {
const webmFirst = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const mp4First = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['mp4', 'webm']
})
expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First))
})
})

View File

@@ -0,0 +1,49 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
/** @knipIgnoreUsedByStackedPR */
export type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat
}
const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = {
webm: 'video/webm',
mp4: 'video/mp4'
}
type BuildArgs = {
name: string
baseUrl: string
width: number
formats: VideoFormat[]
}
/**
* Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`,
* matching the output of `apps/website/scripts/process-videos.sh`.
*/
export function buildVideoSources({
name,
baseUrl,
width,
formats
}: BuildArgs): VideoSource[] {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
return formats.map((format) => ({
src: `${base}/${name}-${width}.${format}`,
type: MIME_TYPES[format],
format
}))
}
/**
* Stable identifier for a list of video sources, suitable as a Vue `key`.
* Browsers do not reload a `<video>` when nested `<source>` children change;
* keying the parent forces a remount when the source set changes.
*/
export function videoKey(sources: VideoSource[]): string {
return sources.map((s) => s.src).join('|')
}

View File

@@ -54,6 +54,9 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -74,6 +74,7 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tanstack/vue-query": "catalog:",
"@tanstack/vue-virtual": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",

404
pnpm-lock.yaml generated
View File

@@ -108,6 +108,9 @@ catalogs:
'@tailwindcss/vite':
specifier: ^4.2.0
version: 4.2.0
'@tanstack/vue-query':
specifier: ^5.83.0
version: 5.100.9
'@tanstack/vue-virtual':
specifier: ^3.13.12
version: 3.13.12
@@ -476,6 +479,9 @@ importers:
'@sparkjsdev/spark':
specifier: 'catalog:'
version: 0.1.10
'@tanstack/vue-query':
specifier: 'catalog:'
version: 5.100.9(vue@3.5.13(typescript@5.9.3))
'@tanstack/vue-virtual':
specifier: 'catalog:'
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
@@ -851,7 +857,7 @@ importers:
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.2.6
@@ -997,7 +1003,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/design-system:
dependencies:
@@ -2651,6 +2657,41 @@ packages:
cpu: [x64]
os: [win32]
'@inquirer/ansi@2.0.5':
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/confirm@6.0.12':
resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@11.1.9':
resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@2.0.5':
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/type@4.0.5':
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@internationalized/date@3.9.0':
resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==}
@@ -2786,6 +2827,10 @@ packages:
'@mixpanel/rrweb@2.0.0-alpha.18.2':
resolution: {integrity: sha512-J3dVTEu6Z4p8di7y9KKvUooNuBjX97DdG6XGWoPEPi07A9512h9M8MEtvlY3mK0PGfuC0Mz5Pv/Ws6gjGYfKQg==}
'@mswjs/interceptors@0.41.8':
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
engines: {node: '>=18'}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -2927,6 +2972,18 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@open-draft/deferred-promise@3.0.0':
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
'@open-draft/logger@0.3.0':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/api-logs@0.208.0':
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
@@ -4197,9 +4254,25 @@ packages:
peerDependencies:
vite: ^8.0.0
'@tanstack/match-sorter-utils@8.19.4':
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
'@tanstack/query-core@5.100.9':
resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==}
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@tanstack/vue-query@5.100.9':
resolution: {integrity: sha512-wGiv/AirRuITlTDl87zdBRaZIZTejMItUswKgMzzcX/1gfn95iKw2EaCuz7qlX9ceB0DwBj9FqaroLnDoJCecg==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.6.0 || ^3.3.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@tanstack/vue-virtual@3.13.12':
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
peerDependencies:
@@ -4505,9 +4578,15 @@ packages:
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/set-cookie-parser@2.4.10':
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/three@0.169.0':
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
@@ -5599,6 +5678,10 @@ packages:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -6449,6 +6532,12 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-unique-numbers@9.0.22:
resolution: {integrity: sha512-dBR+30yHAqBGvOuxxQdnn2lTLHCO6r/9B+M4yF8mNrzr3u1yiF+YVJ6u3GTyPN/VRWqaE1FcscZDdBgVKmrmQQ==}
engines: {node: '>=18.2.0'}
@@ -6456,6 +6545,9 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-wrap-ansi@0.2.0:
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
fastest-levenshtein@1.0.16:
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
engines: {node: '>= 4.9.1'}
@@ -6732,6 +6824,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphql@16.13.2:
resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
gray-matter@4.0.3:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
@@ -6814,6 +6910,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -7070,6 +7169,9 @@ packages:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
is-node-process@1.2.0:
resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
is-npm@6.1.0:
resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -7915,6 +8017,16 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msw@2.14.3:
resolution: {integrity: sha512-kk8G5cocVlJ4wsKMGZegn2H6XLOEKjbA+nSJE2354e/SRp4mDicCHUYnMXpymzVcVDCs+GUAsmNqSn+yHv4T2A==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
typescript: '>= 4.8.x'
peerDependenciesMeta:
typescript:
optional: true
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -7922,6 +8034,10 @@ packages:
resolution: {integrity: sha512-SsI/exkodHsh+ofCV7An2PZWRaJC7eFVl7gtHQlMWFEDmWtb7cELr/GK32Nhe/6dZQhbr81o+Moswx9aXN3RRg==}
engines: {node: '>=18.2.0'}
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -8106,6 +8222,9 @@ packages:
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -8234,6 +8353,9 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -8679,6 +8801,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
request-light@0.5.8:
resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==}
@@ -8737,6 +8862,9 @@ packages:
retext@9.0.0:
resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
rettime@0.11.11:
resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -8832,6 +8960,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -8965,6 +9096,10 @@ packages:
standardized-audio-context@25.3.77:
resolution: {integrity: sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -8984,6 +9119,9 @@ packages:
stream-replace-string@2.0.0:
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -9240,6 +9378,10 @@ packages:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -9318,6 +9460,10 @@ packages:
resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
engines: {node: '>=20'}
type-fest@5.6.0:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -9574,6 +9720,9 @@ packages:
uploadthing:
optional: true
until-async@3.0.2:
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
update-browserslist-db@1.2.2:
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
hasBin: true
@@ -9883,8 +10032,8 @@ packages:
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
vue-component-type-helpers@3.2.7:
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
vue-component-type-helpers@3.2.8:
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -12057,6 +12206,64 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@inquirer/ansi@2.0.5':
optional: true
'@inquirer/confirm@6.0.12(@types/node@24.10.4)':
dependencies:
'@inquirer/core': 11.1.9(@types/node@24.10.4)
'@inquirer/type': 4.0.5(@types/node@24.10.4)
optionalDependencies:
'@types/node': 24.10.4
optional: true
'@inquirer/confirm@6.0.12(@types/node@25.0.3)':
dependencies:
'@inquirer/core': 11.1.9(@types/node@25.0.3)
'@inquirer/type': 4.0.5(@types/node@25.0.3)
optionalDependencies:
'@types/node': 25.0.3
optional: true
'@inquirer/core@11.1.9(@types/node@24.10.4)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@24.10.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 24.10.4
optional: true
'@inquirer/core@11.1.9(@types/node@25.0.3)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@25.0.3)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 25.0.3
optional: true
'@inquirer/figures@2.0.5':
optional: true
'@inquirer/type@4.0.5(@types/node@24.10.4)':
optionalDependencies:
'@types/node': 24.10.4
optional: true
'@inquirer/type@4.0.5(@types/node@25.0.3)':
optionalDependencies:
'@types/node': 25.0.3
optional: true
'@internationalized/date@3.9.0':
dependencies:
'@swc/helpers': 0.5.17
@@ -12296,6 +12503,16 @@ snapshots:
base64-arraybuffer: 1.0.2
mitt: 3.0.1
'@mswjs/interceptors@0.41.8':
dependencies:
'@open-draft/deferred-promise': 2.2.0
'@open-draft/logger': 0.3.0
'@open-draft/until': 2.1.0
is-node-process: 1.2.0
outvariant: 1.4.3
strict-event-emitter: 0.5.1
optional: true
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@@ -12502,7 +12719,7 @@ snapshots:
tsconfig-paths: 4.2.0
tslib: 2.8.1
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -12522,7 +12739,7 @@ snapshots:
tslib: 2.8.1
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -12551,6 +12768,21 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@open-draft/deferred-promise@2.2.0':
optional: true
'@open-draft/deferred-promise@3.0.0':
optional: true
'@open-draft/logger@0.3.0':
dependencies:
is-node-process: 1.2.0
outvariant: 1.4.3
optional: true
'@open-draft/until@2.1.0':
optional: true
'@opentelemetry/api-logs@0.208.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -13405,7 +13637,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.7
vue-component-type-helpers: 3.2.8
'@swc/helpers@0.5.17':
dependencies:
@@ -13486,8 +13718,22 @@ snapshots:
tailwindcss: 4.2.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
'@tanstack/query-core@5.100.9': {}
'@tanstack/virtual-core@3.13.12': {}
'@tanstack/vue-query@5.100.9(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@tanstack/match-sorter-utils': 8.19.4
'@tanstack/query-core': 5.100.9
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.3))
'@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@tanstack/virtual-core': 3.13.12
@@ -13832,8 +14078,16 @@ snapshots:
'@types/semver@7.7.0': {}
'@types/set-cookie-parser@2.4.10':
dependencies:
'@types/node': 25.0.3
optional: true
'@types/stats.js@0.17.3': {}
'@types/statuses@2.0.6':
optional: true
'@types/three@0.169.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
@@ -14118,7 +14372,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -14139,20 +14393,22 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@vitest/mocker@4.0.16(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.3(@types/node@24.10.4)(typescript@5.9.3)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@vitest/mocker@4.0.16(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.3(@types/node@25.0.3)(typescript@5.9.3)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
@@ -14189,7 +14445,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -15227,6 +15483,9 @@ snapshots:
slice-ansi: 7.1.2
string-width: 8.2.0
cli-width@4.1.0:
optional: true
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -16229,6 +16488,14 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-string-truncated-width@3.0.3:
optional: true
fast-string-width@3.0.2:
dependencies:
fast-string-truncated-width: 3.0.3
optional: true
fast-unique-numbers@9.0.22:
dependencies:
'@babel/runtime': 7.29.2
@@ -16236,6 +16503,11 @@ snapshots:
fast-uri@3.1.0: {}
fast-wrap-ansi@0.2.0:
dependencies:
fast-string-width: 3.0.2
optional: true
fastest-levenshtein@1.0.16: {}
fastq@1.20.1:
@@ -16552,6 +16824,9 @@ snapshots:
graceful-fs@4.2.11: {}
graphql@16.13.2:
optional: true
gray-matter@4.0.3:
dependencies:
js-yaml: 3.14.2
@@ -16697,6 +16972,12 @@ snapshots:
he@1.2.0: {}
headers-polyfill@5.0.1:
dependencies:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.0
optional: true
hookable@5.5.3: {}
hookified@1.14.0: {}
@@ -16958,6 +17239,9 @@ snapshots:
is-negative-zero@2.0.3:
optional: true
is-node-process@1.2.0:
optional: true
is-npm@6.1.0: {}
is-number-object@1.1.1:
@@ -17954,6 +18238,58 @@ snapshots:
ms@2.1.3: {}
msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 6.0.12(@types/node@24.10.4)
'@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.13.2
headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.11.11
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
type-fest: 5.6.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@types/node'
optional: true
msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 6.0.12(@types/node@25.0.3)
'@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.13.2
headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.11.11
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
type-fest: 5.6.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@types/node'
optional: true
muggle-string@0.4.1: {}
multi-buffer-data-view@6.0.22:
@@ -17961,6 +18297,9 @@ snapshots:
'@babel/runtime': 7.29.2
tslib: 2.8.1
mute-stream@3.0.0:
optional: true
nanoid@3.3.11: {}
nanoid@5.1.5: {}
@@ -18206,6 +18545,9 @@ snapshots:
orderedmap@2.1.1: {}
outvariant@1.4.3:
optional: true
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
@@ -18415,6 +18757,9 @@ snapshots:
lru-cache: 11.2.6
minipass: 7.1.3
path-to-regexp@6.3.0:
optional: true
path-type@4.0.0: {}
pathe@0.2.0: {}
@@ -19016,6 +19361,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remove-accents@0.5.0: {}
request-light@0.5.8: {}
request-light@0.7.0: {}
@@ -19078,6 +19425,9 @@ snapshots:
retext-stringify: 4.0.0
unified: 11.0.5
rettime@0.11.11:
optional: true
reusify@1.1.0: {}
rfdc@1.4.1: {}
@@ -19200,6 +19550,9 @@ snapshots:
semver@7.7.4: {}
set-cookie-parser@3.1.0:
optional: true
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -19381,6 +19734,9 @@ snapshots:
automation-events: 7.1.11
tslib: 2.8.1
statuses@2.0.2:
optional: true
std-env@3.10.0: {}
stop-iteration-iterator@1.1.0:
@@ -19413,6 +19769,9 @@ snapshots:
stream-replace-string@2.0.0: {}
strict-event-emitter@0.5.1:
optional: true
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -19714,6 +20073,11 @@ snapshots:
dependencies:
tldts: 7.0.19
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.19
optional: true
tr46@0.0.3: {}
tr46@6.0.0:
@@ -19782,6 +20146,11 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
type-fest@5.6.0:
dependencies:
tagged-tag: 1.0.0
optional: true
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -20045,6 +20414,9 @@ snapshots:
ofetch: 1.5.1
ufo: 1.6.3
until-async@3.0.2:
optional: true
update-browserslist-db@1.2.2(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@@ -20334,10 +20706,10 @@ snapshots:
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/mocker': 4.0.16(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20376,10 +20748,10 @@ snapshots:
- tsx
- yaml
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/mocker': 4.0.16(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20530,7 +20902,7 @@ snapshots:
vue-component-type-helpers@3.2.6: {}
vue-component-type-helpers@3.2.7: {}
vue-component-type-helpers@3.2.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:

View File

@@ -37,6 +37,7 @@ catalog:
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@tanstack/vue-query': ^5.83.0
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Generate test fixture files for metadata parser tests.
Each fixture embeds the same workflow and prompt JSON, matching the
format the ComfyUI backend uses to write metadata.
Prerequisites:
source ~/ComfyUI/.venv/bin/activate
python3 scripts/generate-embedded-metadata-test-files.py
Output: src/scripts/metadata/__fixtures__/
"""
import json
import os
import struct
import subprocess
import av
from PIL import Image
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
WORKFLOW = {
'nodes': [
{
'id': 1,
'type': 'KSampler',
'pos': [100, 100],
'size': [200, 200],
}
]
}
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
def out(name: str) -> str:
return os.path.join(FIXTURES_DIR, name)
def report(name: str):
size = os.path.getsize(out(name))
print(f' {name} ({size} bytes)')
def make_1x1_image() -> Image.Image:
return Image.new('RGB', (1, 1), (255, 0, 0))
def build_exif_bytes() -> bytes:
"""Build EXIF bytes matching the backend's tag assignments.
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
"""
img = make_1x1_image()
exif = img.getexif()
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
exif[0x0110] = f'prompt:{PROMPT_JSON}'
return exif.tobytes()
def inject_exif_prefix_in_webp(path: str):
"""Prepend Exif\\0\\0 to the EXIF chunk in a WEBP file.
PIL always strips this prefix, so we re-inject it to test that code path.
"""
data = bytearray(open(path, 'rb').read())
off = 12
while off < len(data):
chunk_type = data[off:off + 4]
chunk_len = struct.unpack_from('<I', data, off + 4)[0]
if chunk_type == b'EXIF':
prefix = b'Exif\x00\x00'
data[off + 8:off + 8] = prefix
struct.pack_into('<I', data, off + 4, chunk_len + len(prefix))
riff_size = struct.unpack_from('<I', data, 4)[0]
struct.pack_into('<I', data, 4, riff_size + len(prefix))
break
off += 8 + chunk_len + (chunk_len % 2)
with open(path, 'wb') as f:
f.write(data)
def generate_av_fixture(
name: str,
fmt: str,
codec: str,
rate: int = 44100,
options: dict | None = None,
):
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
path = out(name)
container = av.open(path, mode='w', format=fmt, options=options or {})
stream = container.add_stream(codec, rate=rate)
stream.layout = 'mono'
container.metadata['prompt'] = PROMPT_JSON
container.metadata['workflow'] = WORKFLOW_JSON
sample_fmt = stream.codec_context.codec.audio_formats[0].name
samples = stream.codec_context.frame_size or 1024
frame = av.AudioFrame(format=sample_fmt, layout='mono', samples=samples)
frame.rate = rate
frame.pts = 0
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
container.close()
report(name)
def generate_webp():
img = make_1x1_image()
exif = build_exif_bytes()
img.save(out('with_metadata.webp'), 'WEBP', exif=exif)
report('with_metadata.webp')
img.save(out('with_metadata_exif_prefix.webp'), 'WEBP', exif=exif)
inject_exif_prefix_in_webp(out('with_metadata_exif_prefix.webp'))
report('with_metadata_exif_prefix.webp')
def generate_avif():
img = make_1x1_image()
exif = build_exif_bytes()
img.save(out('with_metadata.avif'), 'AVIF', exif=exif)
report('with_metadata.avif')
def generate_flac():
generate_av_fixture('with_metadata.flac', 'flac', 'flac')
def generate_opus():
generate_av_fixture('with_metadata.opus', 'opus', 'libopus', rate=48000)
def generate_mp3():
generate_av_fixture('with_metadata.mp3', 'mp3', 'libmp3lame')
def generate_mp4():
"""Generate MP4 via ffmpeg CLI with QuickTime keys/ilst metadata."""
path = out('with_metadata.mp4')
subprocess.run([
'ffmpeg', '-y', '-loglevel', 'error',
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
'-movflags', 'use_metadata_tags',
'-metadata', f'prompt={PROMPT_JSON}',
'-metadata', f'workflow={WORKFLOW_JSON}',
path,
], check=True)
report('with_metadata.mp4')
def generate_webm():
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
if __name__ == '__main__':
print('Generating fixtures...')
generate_webp()
generate_avif()
generate_flac()
generate_opus()
generate_mp3()
generate_mp4()
generate_webm()
print('Done.')

View File

@@ -0,0 +1,32 @@
import axios from 'axios'
const PAYLOAD_KEY_SAMPLE = 10
export function summarizeError(err: unknown): Record<string, unknown> {
if (axios.isAxiosError(err)) {
return {
message: err.message,
code: err.code,
status: err.response?.status
}
}
if (err instanceof Error) {
return { message: err.message, name: err.name }
}
return { message: String(err) }
}
export function summarizePayload(data: unknown): Record<string, unknown> {
if (data === null) return { type: 'null' }
if (data === undefined) return { type: 'undefined' }
if (Array.isArray(data)) return { type: 'array', length: data.length }
if (typeof data === 'object') {
const keys = Object.keys(data as Record<string, unknown>)
return {
type: 'object',
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
keyCount: keys.length
}
}
return { type: typeof data }
}

View File

@@ -0,0 +1,49 @@
import * as fc from 'fast-check'
import { describe, expect, it } from 'vitest'
import { mapToDropdownItem } from '@/base/remote/itemSchema'
describe('mapToDropdownItem property tests', () => {
it('mapping is total and stable for arbitrary string fields', () => {
fc.assert(
fc.property(
fc.record({
id: fc.string(),
name: fc.string()
}),
(raw) => {
const schema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image' as const
}
const a = mapToDropdownItem(raw, schema)
const b = mapToDropdownItem(raw, schema)
expect(a).toEqual(b)
expect(typeof a.id).toBe('string')
expect(typeof a.name).toBe('string')
}
)
)
})
it('id is non-empty when value_field is present in raw', () => {
fc.assert(
fc.property(
fc.record({
id: fc.string({ minLength: 1 }),
name: fc.string()
}),
(raw) => {
const schema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image' as const
}
const item = mapToDropdownItem(raw, schema)
expect(item.id.length).toBeGreaterThan(0)
}
)
)
})
})

View File

@@ -0,0 +1,354 @@
import { describe, expect, it } from 'vitest'
import {
buildSearchText,
displayName,
extractItems,
getByPath,
mapToDropdownItem,
resolveLabel
} from '@/base/remote/itemSchema'
describe('getByPath', () => {
it('returns a top-level value for a plain key', () => {
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
})
it('traverses nested objects via dot-path', () => {
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
'Alice'
)
})
it('treats numeric segments as array indices', () => {
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
})
it('combines nested objects and array indices', () => {
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
})
it('returns undefined for a missing top-level key', () => {
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
})
it('returns undefined when traversing past a null segment', () => {
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
})
it('returns undefined when the root is null', () => {
expect(getByPath(null, 'a')).toBeUndefined()
})
it('returns undefined when the root is undefined', () => {
expect(getByPath(undefined, 'a')).toBeUndefined()
})
it('returns undefined for an out-of-bounds array index', () => {
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
})
})
describe('resolveLabel', () => {
it('resolves a plain dot-path to its value', () => {
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
})
it('resolves a nested dot-path without placeholders', () => {
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
'Alice'
)
})
it('substitutes a single {field} placeholder', () => {
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
})
it('substitutes multiple placeholders', () => {
expect(
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
).toBe('Alice Liddell')
})
it('substitutes placeholders with dot-paths', () => {
expect(
resolveLabel('{profile.name} ({profile.age})', {
profile: { name: 'Alice', age: 30 }
})
).toBe('Alice (30)')
})
it('replaces missing placeholder fields with an empty string', () => {
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
'Alice - '
)
})
it('returns an empty string when a plain path resolves to undefined', () => {
expect(resolveLabel('missing', { a: 1 })).toBe('')
})
it('coerces numeric values to strings', () => {
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
})
})
describe('mapToDropdownItem', () => {
it('maps required fields to id and name', () => {
const item = mapToDropdownItem(
{ voice_id: 'v1', label: 'Roger' },
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
)
expect(item).toEqual({
id: 'v1',
name: 'Roger',
description: undefined,
preview_url: undefined
})
})
it('includes description when description_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
{
value_field: 'id',
label_field: 'label',
description_field: 'desc',
preview_type: 'image'
}
)
expect(item.description).toBe('Laid-back American male')
})
it('includes preview_url when preview_url_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
{
value_field: 'id',
label_field: 'label',
preview_url_field: 'sample',
preview_type: 'audio'
}
)
expect(item.preview_url).toBe('https://example.com/a.mp3')
})
it('resolves label_field templates with placeholders', () => {
const item = mapToDropdownItem(
{ id: 'v1', first: 'Alice', last: 'Liddell' },
{
value_field: 'id',
label_field: '{first} {last}',
preview_type: 'image'
}
)
expect(item.name).toBe('Alice Liddell')
})
it('resolves dot-path fields for nested data', () => {
const item = mapToDropdownItem(
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
{
value_field: 'task_result.elements.0.element_id',
label_field: 'task_result.elements.0.name',
preview_type: 'image'
}
)
expect(item.id).toBe('e1')
expect(item.name).toBe('Elem')
})
it('stringifies non-string value_field', () => {
const item = mapToDropdownItem(
{ id: 42, label: 'Answer' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('42')
})
it('returns an empty string id when value_field is missing', () => {
const item = mapToDropdownItem(
{ label: 'Orphan' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('')
})
})
describe('extractItems', () => {
it('returns the full response when responseKey is undefined', () => {
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
})
it('extracts items from a top-level key', () => {
expect(
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
).toEqual([{ id: 'a' }, { id: 'b' }])
})
it('extracts items via a dot-path', () => {
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
1, 2
])
})
it('returns an empty array for a valid empty list', () => {
expect(extractItems([])).toEqual([])
})
it('returns null when the path does not exist', () => {
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
})
it('returns null when the path resolves to a non-array', () => {
expect(
extractItems({ data: { items: 'not an array' } }, 'data.items')
).toBeNull()
})
it('returns null when the full response is not an array', () => {
expect(extractItems({ not: 'array' })).toBeNull()
})
it('returns null when response is null', () => {
expect(extractItems(null)).toBeNull()
})
})
describe('buildSearchText', () => {
it('joins multiple fields with a space', () => {
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
'hello world'
)
})
it('lowercases the result', () => {
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
})
it('drops missing fields', () => {
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
'alice'
)
})
it('supports dot-path fields', () => {
expect(
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
'profile.name',
'profile.age'
])
).toBe('alice 30')
})
it('returns an empty string when all fields are missing', () => {
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
})
})
describe('mapToDropdownItem preview_url normalization', () => {
const baseSchema = {
value_field: 'id',
label_field: 'name',
preview_url_field: 'thumb',
preview_type: 'image' as const
}
it('preserves absolute https URLs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'https://cdn.example.com/a.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('https://cdn.example.com/a.png')
})
it('preserves protocol-relative URLs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '//cdn.example.com/a.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('//cdn.example.com/a.png')
})
it('preserves data: URIs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'data:image/png;base64,AAA' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('data:image/png;base64,AAA')
})
it('preserves blob: URLs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'blob:https://app/abc' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('blob:https://app/abc')
})
it('joins relative paths against the previewBaseUrl', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '/voices/1/preview.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('https://api.comfy.org/voices/1/preview.png')
})
it('adds a leading slash when relative path lacks one', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'voices/1/preview.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('https://api.comfy.org/voices/1/preview.png')
})
it('strips trailing slashes from previewBaseUrl', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '/x.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org/' }
)
expect(item.preview_url).toBe('https://api.comfy.org/x.png')
})
it('returns relative path unchanged when no previewBaseUrl is provided', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '/x.png' },
baseSchema
)
expect(item.preview_url).toBe('/x.png')
})
it('returns undefined when preview_url_field is unset', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A' },
{ value_field: 'id', label_field: 'name', preview_type: 'image' },
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBeUndefined()
})
})
describe('displayName', () => {
it('returns name when present', () => {
expect(displayName({ id: 'abc', name: 'Cool Asset' })).toBe('Cool Asset')
})
it('falls back to id when name is empty string', () => {
expect(displayName({ id: 'abc-123', name: '' })).toBe('abc-123')
})
})

View File

@@ -0,0 +1,91 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
export interface DropdownItemShape {
id: string
name: string
description?: string
preview_url?: string
}
/**
* User-facing label for a dropdown item. Falls back to id when name
* is missing or empty, so trigger/list rows never render blank.
*/
export function displayName(item: DropdownItemShape): string {
return item.name || item.id
}
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:)?\/\//i
const DATA_URL_PREFIX = 'data:'
const BLOB_URL_PREFIX = 'blob:'
function resolvePreviewUrl(
raw: string | undefined,
baseUrl?: string
): string | undefined {
if (!raw) return undefined
const lowered = raw.toLowerCase()
if (
ABSOLUTE_URL_REGEX.test(raw) ||
lowered.startsWith(DATA_URL_PREFIX) ||
lowered.startsWith(BLOB_URL_PREFIX)
) {
return raw
}
if (!baseUrl) return raw
const normalizedBase = baseUrl.replace(/\/+$/, '')
const normalizedPath = raw.startsWith('/') ? raw : `/${raw}`
return normalizedBase + normalizedPath
}
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema,
options: { previewBaseUrl?: string } = {}
): DropdownItemShape {
const previewRaw = schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: resolvePreviewUrl(previewRaw, options.previewBaseUrl)
}
}
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] | null {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : null
}
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}

17
src/base/remote/retry.ts Normal file
View File

@@ -0,0 +1,17 @@
import axios from 'axios'
const BACKOFF_BASE_MS = 1000
const BACKOFF_CAP_MS = 16000
export function getBackoff(retryCount: number): number {
return Math.min(BACKOFF_BASE_MS * Math.pow(2, retryCount), BACKOFF_CAP_MS)
}
export function isRetriableError(err: unknown): boolean {
if (!axios.isAxiosError(err)) return true
if (err.code === 'ERR_CANCELED') return false
const status = err.response?.status
if (status == null) return true
if (status >= 500) return true
return status === 408 || status === 429
}

View File

@@ -0,0 +1,187 @@
import { AxiosError, AxiosHeaders } from 'axios'
import { describe, expect, it } from 'vitest'
import { getBackoff, isRetriableError } from '@/base/remote/retry'
import { summarizeError, summarizePayload } from '@/base/remote/diagnostics'
describe('getBackoff', () => {
it('grows exponentially from 1s', () => {
expect(getBackoff(1)).toBe(2000)
expect(getBackoff(2)).toBe(4000)
expect(getBackoff(3)).toBe(8000)
expect(getBackoff(4)).toBe(16000)
})
it('caps at 16s for higher attempt counts', () => {
expect(getBackoff(5)).toBe(16000)
expect(getBackoff(10)).toBe(16000)
expect(getBackoff(100)).toBe(16000)
})
})
describe('isRetriableError', () => {
function axiosErrorWithStatus(status: number): AxiosError {
return new AxiosError(
`HTTP ${status}`,
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
}
it('retries non-axios errors (e.g. unexpected throws)', () => {
expect(isRetriableError(new Error('boom'))).toBe(true)
expect(isRetriableError('string error')).toBe(true)
expect(isRetriableError(undefined)).toBe(true)
})
it('retries axios errors with no response (network failures)', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(isRetriableError(err)).toBe(true)
})
it('retries 5xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
})
it('retries 408 (request timeout) and 429 (too many requests)', () => {
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
})
it('does not retry other 4xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
})
})
describe('summarizeError', () => {
it('extracts message, code and status from an axios error', () => {
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status: 500,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
expect(summarizeError(err)).toEqual({
message: 'Request failed',
code: 'ERR_BAD_RESPONSE',
status: 500
})
})
it('does not include axios config, headers, request or response data', () => {
const authedConfig = {
url: '/voices',
method: 'get',
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
}
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
authedConfig,
undefined,
{
status: 500,
statusText: '',
headers: { 'set-cookie': ['session=PRIVATE'] },
config: authedConfig,
data: { user_email: 'private@example.com' }
}
)
const summary = summarizeError(err)
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
expect(JSON.stringify(summary)).not.toContain('private@example.com')
expect(summary).not.toHaveProperty('config')
expect(summary).not.toHaveProperty('request')
expect(summary).not.toHaveProperty('response')
})
it('reports an axios network error with no response as undefined status', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(summarizeError(err)).toEqual({
message: 'Network Error',
code: 'ERR_NETWORK',
status: undefined
})
})
it('summarizes a plain Error using its name and message', () => {
expect(summarizeError(new TypeError('boom'))).toEqual({
message: 'boom',
name: 'TypeError'
})
})
it('coerces non-Error throwables to a message string', () => {
expect(summarizeError('oops')).toEqual({ message: 'oops' })
expect(summarizeError(42)).toEqual({ message: '42' })
expect(summarizeError(null)).toEqual({ message: 'null' })
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
})
})
describe('summarizePayload', () => {
it('reports array length without exposing values', () => {
expect(
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
).toEqual({
type: 'array',
length: 3
})
})
it('reports object keys without exposing values', () => {
expect(
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
).toEqual({
type: 'object',
keys: ['user_email', 'voices'],
keyCount: 2
})
})
it('caps the keys sample at 10 but reports the full key count', () => {
const big: Record<string, number> = {}
for (let i = 0; i < 25; i++) big[`k${i}`] = i
const summary = summarizePayload(big) as {
type: string
keys: string[]
keyCount: number
}
expect(summary.type).toBe('object')
expect(summary.keys).toHaveLength(10)
expect(summary.keyCount).toBe(25)
})
it('distinguishes null and undefined', () => {
expect(summarizePayload(null)).toEqual({ type: 'null' })
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
})
it('reports primitive types without their value', () => {
expect(summarizePayload('hello')).toEqual({ type: 'string' })
expect(summarizePayload(123)).toEqual({ type: 'number' })
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
})
})

View File

@@ -98,6 +98,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
@@ -107,6 +108,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
@@ -119,6 +121,7 @@ const onClearHistoryFromMenu = (close: () => void) => {
}
const onToggleDockedJobHistory = async (close: () => void) => {
trackFeatureUsed()
close()
try {
@@ -138,6 +141,7 @@ const onToggleDockedJobHistory = async (close: () => void) => {
}
const onToggleRunProgressBar = async () => {
trackFeatureUsed()
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value

View File

@@ -13,7 +13,7 @@
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-job-tab="onUpdateSelectedJobTab"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@@ -50,6 +50,7 @@ import type {
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -81,6 +82,7 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -95,6 +97,11 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
emit('update:selectedJobTab', value)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)

View File

@@ -66,6 +66,7 @@ import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -93,6 +94,7 @@ const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const {
totalPercentFormatted,
@@ -188,6 +190,7 @@ const {
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const jobId = item.taskRef?.jobId
if (!jobId) return
@@ -209,6 +212,7 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
@@ -224,10 +228,12 @@ const setExpanded = (expanded: boolean) => {
}
const viewAllJobs = () => {
trackFeatureUsed()
setExpanded(true)
}
const toggleAssetsSidebar = () => {
trackFeatureUsed()
sidebarTabStore.toggleSidebarTab('assets')
}
@@ -257,12 +263,14 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
trackFeatureUsed()
await openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
// Capture pending jobIds before clearing
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
@@ -275,6 +283,7 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const tasks = queueStore.runningTasks
const jobIds = tasks
.map((task) => task.jobId)
@@ -298,6 +307,7 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
})
const onClearHistoryFromMenu = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -122,6 +122,7 @@ import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
const {
hideShowAssetsAction = false,
@@ -147,6 +148,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
@@ -170,6 +172,7 @@ const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
trackFeatureUsed()
selectWorkflowFilter(value)
close()
}
@@ -179,6 +182,7 @@ const selectSortMode = (value: JobSortMode) => {
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
trackFeatureUsed()
selectSortMode(value)
close()
}

View File

@@ -2,15 +2,16 @@
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
<JobHistoryActionsMenu @clear-history="onClearHistory" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
v-model:selected-job-tab="selectedJobTab"
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="onUpdateSelectedJobTab"
/>
</div>
<JobFilterActions
@@ -81,13 +82,14 @@ import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem, JobTab } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -104,6 +106,17 @@ const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const onClearHistory = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
selectedJobTab.value = value
}
const {
selectedJobTab,
selectedWorkflowFilter,
@@ -145,6 +158,7 @@ const activeQueueSummary = computed(() => {
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
@@ -160,6 +174,7 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
@@ -194,10 +209,12 @@ const { jobMenuEntries, cancelJob } = useJobMenu(
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})

View File

@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
progressToast: {
finished: 'Finished',
failed: 'Failed',
pending: 'Pending'
}
}
}
})
function completedJob(): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'controlnet-canny.safetensors',
bytesTotal: 100,
bytesDownloaded: 100,
progress: 1,
status: 'completed',
lastUpdate: Date.now()
}
}
describe('ProgressToastItem — completed state', () => {
it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => {
render(ProgressToastItem, {
props: { job: completedJob() },
global: { plugins: [i18n] }
})
const badge = screen.getByText('Finished')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(badge.closest('.opacity-50')).toBeNull()
const assetName = screen.getByText('controlnet-canny.safetensors')
// eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix
expect(assetName.closest('.opacity-50')).not.toBeNull()
})
})

View File

@@ -22,14 +22,9 @@ const isPending = computed(() => job.status === 'created')
<template>
<div
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3"
>
<div class="min-w-0 flex-1">
<div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')">
<span class="block truncate text-sm text-base-foreground">{{
job.assetName
}}</span>

View File

@@ -102,6 +102,16 @@ function createMeshModel(name = 'TestModel'): THREE.Group {
return group
}
function createPointsModel(name = 'TestModel'): THREE.Group {
const geometry = new THREE.BufferGeometry()
const material = new THREE.PointsMaterial({ color: 0xff0000 })
const points = new THREE.Points(geometry, material)
const group = new THREE.Group()
group.name = name
group.add(points)
return group
}
describe('SceneModelManager', () => {
describe('constructor', () => {
it('initializes default state', () => {
@@ -311,6 +321,20 @@ describe('SceneModelManager', () => {
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
it('disposes points geometry and materials', async () => {
const { manager } = createManager()
const model = createPointsModel()
const points = model.children[0] as THREE.Points
const geoDispose = vi.spyOn(points.geometry, 'dispose')
const matDispose = vi.spyOn(points.material as THREE.Material, 'dispose')
await manager.setupModel(model)
manager.clearModel()
expect(geoDispose).toHaveBeenCalled()
expect(matDispose).toHaveBeenCalled()
})
})
describe('reset', () => {

View File

@@ -328,7 +328,7 @@ export class SceneModelManager implements ModelManagerInterface {
this.scene.remove(obj)
obj.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (child instanceof THREE.Mesh || child instanceof THREE.Points) {
child.geometry?.dispose()
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())

View File

@@ -2700,6 +2700,19 @@
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
},
"remoteCombo": {
"loading": "Loading...",
"loadFailed": "Failed to load options",
"noResults": "No results found",
"refresh": "Refresh options",
"selectAriaLabel": "Select {field}",
"searchAriaLabel": "Search {field}",
"layoutSwitcherAriaLabel": "Layout switcher",
"layoutList": "List view",
"layoutGrid": "Grid view",
"playAudioPreview": "Play audio preview",
"pauseAudioPreview": "Pause audio preview"
},
"valueControl": {
"header": {
"prefix": "Automatically update the value",

View File

@@ -1,6 +1,7 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { initializeApp } from 'firebase/app'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
@@ -11,6 +12,8 @@ import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { createAppQueryClient } from '@/platform/remote/queryClient'
import { getFirebaseConfig } from '@/config/firebase'
import {
configValueOrDefault,
@@ -82,7 +85,9 @@ Sentry.init({
})
})
app.directive('tooltip', Tooltip)
const queryClient = createAppQueryClient()
app
.use(VueQueryPlugin, { queryClient })
.use(router)
.use(PrimeVue, {
theme: {

View File

@@ -1,7 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import {
MISSING_TAG,
assetService,
isBlake3AssetHash,
toBlake3AssetHash
} from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
@@ -44,6 +49,10 @@ vi.mock('@/i18n', () => ({
const fetchApiMock = vi.mocked(api.fetchApi)
const validBlake3Hash =
'1111111111111111111111111111111111111111111111111111111111111111'
const validBlake3AssetHash = `blake3:${validBlake3Hash}`
function buildResponse(
body: unknown,
init: { ok?: boolean; status?: number } = {}
@@ -180,9 +189,98 @@ describe(assetService.getAssetMetadata, () => {
})
})
describe(isBlake3AssetHash, () => {
it('accepts only prefixed 64-character blake3 hashes', () => {
expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true)
expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe(
true
)
expect(isBlake3AssetHash('blake3:abc')).toBe(false)
expect(isBlake3AssetHash(validBlake3Hash)).toBe(false)
})
})
describe(toBlake3AssetHash, () => {
it('normalizes 64-character blake3 hex values to asset hashes', () => {
expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash)
expect(toBlake3AssetHash('abc')).toBeNull()
expect(toBlake3AssetHash(undefined)).toBeNull()
})
})
describe(assetService.uploadAssetFromUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
consoleSpy.mockRestore()
})
it('requires upload responses to include created_new', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
consoleSpy.mockRestore()
})
it('returns validated upload responses with created_new', async () => {
const uploadedAsset = {
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
created_new: true
}
fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset))
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png',
tags: ['input']
})
).resolves.toEqual(uploadedAsset)
})
})
describe(assetService.uploadAssetFromBase64, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('throws before calling the network when data is not a data URL', async () => {
@@ -195,6 +293,63 @@ describe(assetService.uploadAssetFromBase64, () => {
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
fetchSpy.mockRestore()
consoleSpy.mockRestore()
})
it('rejects upload responses with a non-boolean created_new', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
created_new: 'true'
})
)
await assetService.getInputAssetsIncludingPublic()
await expect(
assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt',
tags: ['input']
})
).rejects.toThrow('Failed to upload asset')
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
fetchSpy.mockRestore()
consoleSpy.mockRestore()
})
})
describe(assetService.uploadAssetAsync, () => {
@@ -354,3 +509,391 @@ describe(assetService.getAssetsByTag, () => {
expect(params.get('include_public')).toBe('true')
})
})
describe(assetService.getAllAssetsByTag, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'c', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['a', 'b', 'c'])
const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string
const firstParams = new URL(firstUrl, 'http://localhost').searchParams
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('limit')).toBe('2')
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
})
it('paginates from raw response size before filtering missing-tagged assets', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
]
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
if (typeof secondUrl !== 'string') {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
})
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
has_more: true
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
has_more: false
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 3
})
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
const secondUrl = fetchApiMock.mock.calls[1]?.[0]
if (typeof secondUrl !== 'string') {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
})
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })]
})
)
await assetService.getAllAssetsByTag('input', true, {
limit: 2,
signal: controller.signal
})
expect(fetchApiMock).toHaveBeenCalledWith(expect.any(String), {
signal: controller.signal
})
})
it('stops pagination when aborted between pages', async () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
return buildResponse({
assets: [
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
})
await expect(
assetService.getAllAssetsByTag('input', true, {
limit: 2,
signal: controller.signal
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).toHaveBeenCalledOnce()
})
})
describe(assetService.getInputAssetsIncludingPublic, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('loads input assets with public assets included and reuses the cache', async () => {
const assets = [
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
expect(first).toEqual(assets)
expect(second).toBe(first)
expect(fetchApiMock).toHaveBeenCalledOnce()
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('true')
expect(params.get('limit')).toBe('500')
})
it('fetches fresh input assets after explicit invalidation', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('does not let one caller abort the shared input asset load for other callers', async () => {
const firstController = new AbortController()
const secondController = new AbortController()
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
let resolveResponse!: (response: Response) => void
let serviceSignal: AbortSignal | undefined
fetchApiMock.mockImplementationOnce(async (_url, options) => {
serviceSignal = options?.signal ?? undefined
return await new Promise<Response>((resolve) => {
resolveResponse = resolve
})
})
const first = assetService.getInputAssetsIncludingPublic(
firstController.signal
)
const second = assetService.getInputAssetsIncludingPublic(
secondController.signal
)
firstController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
resolveResponse(buildResponse({ assets }))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('keeps the shared input asset load alive after all callers abort', async () => {
const firstController = new AbortController()
const secondController = new AbortController()
const assets = [validAsset({ id: 'public-input', tags: ['input'] })]
let resolveResponse!: (response: Response) => void
fetchApiMock.mockImplementationOnce(
async () =>
await new Promise<Response>((resolve) => {
resolveResponse = resolve
})
)
const first = assetService.getInputAssetsIncludingPublic(
firstController.signal
)
const second = assetService.getInputAssetsIncludingPublic(
secondController.signal
)
firstController.abort()
secondController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
resolveResponse(buildResponse({ assets }))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
assets
)
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('does not abort in-flight input asset loads when invalidated', async () => {
const assets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
let resolveResponse!: (response: Response) => void
fetchApiMock
.mockImplementationOnce(
async () =>
await new Promise<Response>((resolve) => {
resolveResponse = resolve
})
)
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
resolveResponse(buildResponse({ assets }))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
freshAssets
)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('invalidates cached input assets after deleting an asset', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(null))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(3)
expect(fetchApiMock.mock.calls[1]).toEqual([
'/assets/stale-input',
expect.objectContaining({ method: 'DELETE' })
])
})
it('invalidates cached input assets after an input asset upload', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/input.png',
tags: ['input']
})
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(3)
})
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
{ ok: true, status: 202 }
)
)
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/input.png',
tags: ['input']
})
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors',
tags: ['models']
})
const cached = await assetService.getInputAssetsIncludingPublic()
expect(cached).toEqual(staleAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
})
describe(assetService.checkAssetHash, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
[200, 'exists'],
[404, 'missing'],
[400, 'invalid']
] as const)('maps %s responses to %s', async (status, expected) => {
const hash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status }))
await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected)
expect(fetchApiMock).toHaveBeenCalledWith(
`/assets/hash/${encodeURIComponent(hash)}`,
{
method: 'HEAD',
signal: undefined
}
)
})
it('throws for unexpected responses', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 }))
await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow(
'Unexpected asset hash check status: 500'
)
})
})

View File

@@ -1,4 +1,5 @@
import { fromZodError } from 'zod-validation-error'
import { z } from 'zod'
import { st } from '@/i18n'
@@ -29,9 +30,14 @@ export interface PaginationOptions {
offset?: number
}
interface AssetPaginationOptions extends PaginationOptions {
signal?: AbortSignal
}
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
includePublic?: boolean
signal?: AbortSignal
}
interface AssetExportOptions {
@@ -170,10 +176,61 @@ 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
export const MODELS_TAG = 'models'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'
/** Result of a HEAD lookup against an exact asset hash. */
export type AssetHashStatus = 'exists' | 'missing' | 'invalid'
const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i
const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i
const uploadedAssetResponseSchema = assetItemSchema.extend({
created_new: z.boolean()
})
/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */
export function isBlake3AssetHash(value: string): boolean {
return BLAKE3_ASSET_HASH_PATTERN.test(value)
}
/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */
export function toBlake3AssetHash(hash: string | undefined): string | null {
if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null
return `blake3:${hash}`
}
function createAbortError(): DOMException {
return new DOMException('Aborted', 'AbortError')
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw createAbortError()
}
async function withCallerAbort<T>(
promise: Promise<T>,
signal?: AbortSignal
): Promise<T> {
throwIfAborted(signal)
if (!signal) return await promise
let removeAbortListener = () => {}
const abortPromise = new Promise<never>((_, reject) => {
const onAbort = () => reject(createAbortError())
signal.addEventListener('abort', onAbort, { once: true })
removeAbortListener = () => signal.removeEventListener('abort', onAbort)
})
try {
return await Promise.race([promise, abortPromise])
} finally {
removeAbortListener()
}
}
/**
* Validates asset response data using Zod schema
*/
@@ -187,11 +244,43 @@ function validateAssetResponse(data: unknown): AssetResponse {
)
}
function validateUploadedAssetResponse(
data: unknown
): AssetItem & { created_new: boolean } {
const result = uploadedAssetResponseSchema.safeParse(data)
if (result.success) {
return result.data
}
console.error('Invalid asset upload response:', fromZodError(result.error))
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
/**
* Private service for asset-related network requests
* Not exposed globally - used internally by ComfyApi
*/
function createAssetService() {
let inputAssetsIncludingPublic: AssetItem[] | null = null
let inputAssetsIncludingPublicRequestId = 0
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
function invalidateInputAssetsIncludingPublic(): void {
inputAssetsIncludingPublicRequestId++
pendingInputAssetsIncludingPublic = null
inputAssetsIncludingPublic = null
}
function invalidateInputAssetsCacheIfNeeded(tags?: string[]): void {
if (tags?.includes('input')) invalidateInputAssetsIncludingPublic()
}
/**
* Handles API response with consistent error handling and Zod validation
*/
@@ -203,7 +292,8 @@ function createAssetService() {
includeTags,
limit = DEFAULT_LIMIT,
offset,
includePublic
includePublic,
signal
} = options
const queryParams = new URLSearchParams({
include_tags: includeTags.join(','),
@@ -217,7 +307,9 @@ function createAssetService() {
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = await api.fetchApi(url)
const res = signal
? await api.fetchApi(url, { signal })
: await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
@@ -403,15 +495,16 @@ function createAssetService() {
* @param options - Pagination options
* @param options.limit - Maximum number of assets to return (default: 500)
* @param options.offset - Number of assets to skip (default: 0)
* @param options.signal - Optional abort signal for cancelling the request
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
*/
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const data = await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic },
{ includeTags: [tag], limit, offset, includePublic, signal },
`assets for tag ${tag}`
)
@@ -420,6 +513,116 @@ function createAssetService() {
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
* @param options - Pagination options
* @param options.limit - Page size for each request (default: 500)
* @param options.signal - Optional abort signal for cancelling requests
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag
*/
async function getAllAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
let offset = 0
while (true) {
if (signal?.aborted) throw createAbortError()
const data = await handleAssetRequest(
{
includeTags: [tag],
limit: pageSize,
offset,
includePublic,
signal
},
`assets for tag ${tag}`
)
const batch = data.assets ?? []
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
const noMoreFromServer = data.has_more === false
const inferredLastPage =
data.has_more === undefined && batch.length < pageSize
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
return assets
}
offset += batch.length
}
}
function startInputAssetsIncludingPublicRequest(): Promise<AssetItem[]> {
const requestId = ++inputAssetsIncludingPublicRequestId
pendingInputAssetsIncludingPublic = getAllAssetsByTag('input', true, {
limit: INPUT_ASSETS_WITH_PUBLIC_LIMIT
})
.then((assets) => {
if (requestId === inputAssetsIncludingPublicRequestId) {
inputAssetsIncludingPublic = assets
}
return assets
})
.finally(() => {
if (requestId === inputAssetsIncludingPublicRequestId) {
pendingInputAssetsIncludingPublic = null
}
})
void pendingInputAssetsIncludingPublic.catch(() => {})
return pendingInputAssetsIncludingPublic
}
/**
* Gets cached input assets including public assets for missing media checks.
* Caller aborts cancel only that caller; shared fetches are invalidated
* through invalidateInputAssetsIncludingPublic().
*/
async function getInputAssetsIncludingPublic(
signal?: AbortSignal
): Promise<AssetItem[]> {
throwIfAborted(signal)
if (inputAssetsIncludingPublic) return inputAssetsIncludingPublic
const request =
pendingInputAssetsIncludingPublic ??
startInputAssetsIncludingPublicRequest()
return await withCallerAbort(request, signal)
}
/**
* Checks whether an asset exists for an exact asset hash.
*
* Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses:
* 200 -> exists, 404 -> missing, and 400 -> invalid hash format.
*/
async function checkAssetHash(
assetHash: string,
signal?: AbortSignal
): Promise<AssetHashStatus> {
const response = await api.fetchApi(
`${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`,
{
method: 'HEAD',
signal
}
)
if (response.status === 200) return 'exists'
if (response.status === 404) return 'missing'
if (response.status === 400) return 'invalid'
throw new Error(`Unexpected asset hash check status: ${response.status}`)
}
/**
* Deletes an asset by ID
* Only available in cloud environment
@@ -438,6 +641,8 @@ function createAssetService() {
`Unable to delete asset ${id}: Server returned ${res.status}`
)
}
invalidateInputAssetsIncludingPublic()
}
/**
@@ -545,7 +750,9 @@ function createAssetService() {
)
}
return await res.json()
const asset = validateUploadedAssetResponse(await res.json())
invalidateInputAssetsCacheIfNeeded(params.tags)
return asset
}
/**
@@ -598,7 +805,9 @@ function createAssetService() {
)
}
return await res.json()
const asset = validateUploadedAssetResponse(await res.json())
invalidateInputAssetsCacheIfNeeded(params.tags)
return asset
}
/**
@@ -628,6 +837,7 @@ function createAssetService() {
if (!parseResult.success) {
throw fromZodError(parseResult.error)
}
invalidateInputAssetsIncludingPublic()
return parseResult.data
}
@@ -658,6 +868,7 @@ function createAssetService() {
if (!parseResult.success) {
throw fromZodError(parseResult.error)
}
invalidateInputAssetsIncludingPublic()
return parseResult.data
}
@@ -709,6 +920,13 @@ function createAssetService() {
)
)
}
if (
params.tags?.includes('input') &&
result.data.type === 'async' &&
result.data.task.status === 'completed'
) {
invalidateInputAssetsIncludingPublic()
}
return result.data
}
@@ -724,6 +942,7 @@ function createAssetService() {
)
)
}
invalidateInputAssetsCacheIfNeeded(params.tags)
return result.data
}
@@ -764,6 +983,10 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
getAllAssetsByTag,
getInputAssetsIncludingPublic,
invalidateInputAssetsIncludingPublic,
checkAssetHash,
deleteAsset,
updateAsset,
addAssetTags,

View File

@@ -1,9 +1,11 @@
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
@@ -13,6 +15,13 @@ import {
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
() => ({
mockCheckAssetHash: vi.fn(),
mockGetInputAssetsIncludingPublic: vi.fn()
})
)
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
@@ -21,6 +30,21 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
) => node._testExecutionId ?? String(node.id)
}))
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
'@/platform/assets/services/assetService'
)
return {
...actual,
assetService: {
...actual.assetService,
checkAssetHash: mockCheckAssetHash,
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
}
}
})
function makeCandidate(
nodeId: string,
name: string,
@@ -70,6 +94,16 @@ function makeGraph(nodes: LGraphNode[]): LGraph {
return fromAny<LGraph, unknown>({ _testNodes: nodes })
}
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
name,
asset_hash: assetHash,
mime_type: null,
tags: ['input']
}
}
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
@@ -232,37 +266,43 @@ describe('groupCandidatesByMediaType', () => {
})
describe('verifyCloudMediaCandidates', () => {
it('marks candidates missing when not in input assets', async () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
'blake3:2222222222222222222222222222222222222222222222222222222222222222'
beforeEach(() => {
vi.clearAllMocks()
mockCheckAssetHash.mockResolvedValue('missing')
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
})
it('marks candidates missing when the asset hash is not found', async () => {
const candidates = [
makeCandidate('1', 'abc123.png', { isMissing: undefined }),
makeCandidate('2', 'def456.png', { isMissing: undefined })
makeCandidate('1', missingHash, { isMissing: undefined }),
makeCandidate('2', existingHash, { isMissing: undefined })
]
const mockStore = {
updateInputs: async () => {},
inputAssets: [{ asset_hash: 'def456.png', name: 'my-photo.png' }]
}
const checkAssetHash = vi.fn(async (assetHash: string) =>
assetHash === existingHash ? ('exists' as const) : ('missing' as const)
)
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
expect(candidates[0].isMissing).toBe(true)
expect(candidates[1].isMissing).toBe(false)
})
it('calls updateInputs before checking assets', async () => {
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
it('uses assetService.checkAssetHash by default', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
mockCheckAssetHash.mockResolvedValue('exists')
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates)
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(updateCalled).toBe(true)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
})
it('respects abort signal before execution', async () => {
@@ -270,69 +310,221 @@ describe('verifyCloudMediaCandidates', () => {
controller.abort()
const candidates = [
makeCandidate('1', 'abc123.png', { isMissing: undefined })
makeCandidate('1', missingHash, { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates, controller.signal)
expect(candidates[0].isMissing).toBeUndefined()
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('respects abort signal after updateInputs', async () => {
it('respects abort signal after hash verification', async () => {
const controller = new AbortController()
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
const checkAssetHash = vi.fn(async () => {
controller.abort()
return 'exists' as const
})
const mockStore = {
updateInputs: async () => {
controller.abort()
},
inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }]
}
await verifyCloudMediaCandidates(candidates, controller.signal, mockStore)
await verifyCloudMediaCandidates(
candidates,
controller.signal,
checkAssetHash
)
expect(candidates[0].isMissing).toBeUndefined()
})
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
const mockStore = {
updateInputs: async () => {},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
await verifyCloudMediaCandidates(candidates)
expect(candidates[0].isMissing).toBe(true)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })]
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
const mockStore = {
updateInputs: async () => {},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
await verifyCloudMediaCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('skips entirely when no pending candidates', async () => {
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates)
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
expect(updateCalled).toBe(false)
it('falls back to input assets for non-blake3 candidate names', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined }),
makeCandidate('2', 'missing.png', { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => [
makeAsset('stored-photo.png', 'photo.png')
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(true)
})
it('uses public input assets for default legacy fallback', async () => {
const candidates = [
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
]
const inputAssets = Array.from({ length: 500 }, (_, index) =>
makeAsset(`asset-${index}.png`)
)
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
await verifyCloudMediaCandidates(candidates)
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
expect(candidates[0].isMissing).toBe(false)
})
it('silences aborts while loading legacy fallback input assets', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => {
controller.abort()
throw abortError
})
await expect(
verifyCloudMediaCandidates(
candidates,
controller.signal,
undefined,
fetchInputAssets
)
).resolves.toBeUndefined()
expect(candidates[0].isMissing).toBeUndefined()
})
it('silences aborts from the default legacy fallback input asset store path', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
controller.abort()
throw abortError
})
await expect(
verifyCloudMediaCandidates(candidates, controller.signal)
).resolves.toBeUndefined()
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
controller.signal
)
expect(candidates[0].isMissing).toBeUndefined()
})
it('falls back to input assets when the hash endpoint returns 400', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
mockCheckAssetHash.mockResolvedValue('invalid')
const fetchInputAssets = vi.fn(async () => [
makeAsset('photo.png', existingHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
})
it('falls back to input assets when hash verification fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
const checkAssetHash = vi.fn(async () => {
throw new Error('network failed')
})
const fetchInputAssets = vi.fn(async () => [
makeAsset('photo.png', existingHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
checkAssetHash,
fetchInputAssets
)
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
expect(warn).toHaveBeenCalledOnce()
warn.mockRestore()
})
it('does not call the hash endpoint for malformed blake3-looking values', async () => {
const malformedHash = 'blake3:abc'
const candidates = [
makeCandidate('1', malformedHash, { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => [
makeAsset('legacy.png', malformedHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
})
it('deduplicates checks for repeated candidate names', async () => {
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined }),
makeCandidate('2', missingHash, { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates)
expect(mockCheckAssetHash).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(true)
expect(candidates[1].isMissing).toBe(true)
})
})

View File

@@ -18,6 +18,12 @@ import {
} 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 type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
isBlake3AssetHash
} from '@/platform/assets/services/assetService'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -106,41 +112,130 @@ export function scanNodeMediaCandidates(
return candidates
}
interface InputVerifier {
updateInputs: () => Promise<unknown>
inputAssets: Array<{ asset_hash?: string | null; name: string }>
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
candidatesByHash: Map<string, MissingMediaCandidate[]>
legacyCandidates: MissingMediaCandidate[]
} {
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
const legacyCandidates: MissingMediaCandidate[] = []
for (const candidate of candidates) {
if (!isBlake3AssetHash(candidate.name)) {
legacyCandidates.push(candidate)
continue
}
const hashCandidates = candidatesByHash.get(candidate.name)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(candidate.name, [candidate])
}
return { candidatesByHash, legacyCandidates }
}
async function verifyCandidatesByHash(
candidatesByHash: Map<string, MissingMediaCandidate[]>,
legacyCandidates: MissingMediaCandidate[],
signal: AbortSignal | undefined,
checkAssetHash: AssetHashVerifier
): Promise<void> {
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
let status: AssetHashStatus
try {
status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Media Pipeline] Failed to verify asset hash:',
err
)
legacyCandidates.push(...hashCandidates)
return
}
if (status === 'invalid') {
legacyCandidates.push(...hashCandidates)
return
}
for (const candidate of hashCandidates) {
candidate.isMissing = status === 'missing'
}
})
)
}
/**
* Verify cloud media candidates against the input assets fetched from the
* assets store. Mutates candidates' `isMissing` in place.
* Verify cloud media candidates by probing the asset hash endpoint first.
* Invalid hash values fall back to the legacy input asset list check.
*/
export async function verifyCloudMediaCandidates(
candidates: MissingMediaCandidate[],
signal?: AbortSignal,
assetsStore?: InputVerifier
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash,
fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
): Promise<void> {
if (signal?.aborted) return
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
const store =
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
const { candidatesByHash, legacyCandidates } =
groupCandidatesForHashLookup(pending)
await verifyCandidatesByHash(
candidatesByHash,
legacyCandidates,
signal,
checkAssetHash
)
await store.updateInputs()
if (signal?.aborted || legacyCandidates.length === 0) return
let inputAssets: AssetItem[]
try {
inputAssets = await fetchInputAssets(signal)
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
throw err
}
if (signal?.aborted) return
const assetHashes = new Set(
store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
)
for (const c of pending) {
c.isMissing = !assetHashes.has(c.name)
for (const candidate of legacyCandidates) {
candidate.isMissing = !assetHashes.has(candidate.name)
}
}
async function fetchMissingInputAssets(
signal?: AbortSignal
): Promise<AssetItem[]> {
return await assetService.getInputAssetsIncludingPublic(signal)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
/** Group confirmed-missing candidates by file name into view models. */
export function groupCandidatesByName(
candidates: MissingMediaCandidate[]

View File

@@ -19,6 +19,11 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
const { mockCheckAssetHash } = vi.hoisted(() => ({
mockCheckAssetHash: vi.fn()
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
@@ -28,6 +33,20 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
) => node._testExecutionId ?? String(node.id)
}))
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
'@/platform/assets/services/assetService'
)
return {
...actual,
assetService: {
...actual.assetService,
checkAssetHash: mockCheckAssetHash
}
}
})
/** Helper: create a combo widget mock */
function makeComboWidget(
name: string,
@@ -43,7 +62,7 @@ function makeComboWidget(
}
/** Helper: create an asset widget mock (Cloud combo replacement) */
function makeAssetWidget(name: string, value: string): IBaseWidget {
function makeAssetWidget(name: string, value: unknown): IBaseWidget {
return fromAny<IBaseWidget, unknown>({
type: 'asset',
name,
@@ -551,6 +570,16 @@ describe('scanAllModelCandidates', () => {
expect(result).toEqual([])
})
it('should skip asset widgets with non-string values', () => {
const graph = makeGraph([
makeNode(1, 'SomeNode', [makeAssetWidget('ckpt_name', 123)])
])
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toEqual([])
})
it('should scan both combo and asset widgets on the same node', () => {
const graph = makeGraph([
makeNode(1, 'DualLoaderNode', [
@@ -1411,6 +1440,7 @@ function makeAssetCandidate(
describe('verifyAssetSupportedCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckAssetHash.mockResolvedValue('missing')
mockIsModelLoading.mockReturnValue(false)
mockHasMore.mockReturnValue(false)
mockGetAssets.mockReturnValue([])
@@ -1428,6 +1458,125 @@ describe('verifyAssetSupportedCandidates', () => {
)
})
it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => {
const hash =
'1111111111111111111111111111111111111111111111111111111111111111'
const candidates = [
makeAssetCandidate('model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockResolvedValue('exists')
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined)
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
})
it('should fall back to asset store matching when the blake3 hash is not found', async () => {
const hash =
'2222222222222222222222222222222222222222222222222222222222222222'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockResolvedValue('missing')
mockGetAssets.mockReturnValue([
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
})
it('should fall back to asset store matching when hash verification fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const hash =
'3333333333333333333333333333333333333333333333333333333333333333'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockRejectedValue(new Error('network failed'))
mockGetAssets.mockReturnValue([
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(warn).toHaveBeenCalledOnce()
warn.mockRestore()
})
it('should skip malformed blake3 hashes and use asset store matching', async () => {
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash: 'abc123',
hashType: 'blake3'
})
]
mockGetAssets.mockReturnValue([
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
await verifyAssetSupportedCandidates(candidates)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(candidates[0].isMissing).toBe(false)
})
it('should not warn or fall back when hash verification is aborted', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const hash =
'4444444444444444444444444444444444444444444444444444444444444444'
const candidates = [
makeAssetCandidate('my_model.safetensors', {
hash,
hashType: 'blake3'
})
]
mockCheckAssetHash.mockRejectedValue(abortError)
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBeUndefined()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})
it('should resolve isMissing=false when asset with matching hash exists', async () => {
const candidates = [
makeAssetCandidate('model.safetensors', {
@@ -1442,6 +1591,7 @@ describe('verifyAssetSupportedCandidates', () => {
await verifyAssetSupportedCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('should resolve isMissing=false when asset with matching filename exists', async () => {

View File

@@ -24,6 +24,11 @@ import {
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
toBlake3AssetHash
} from '@/platform/assets/services/assetService'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -177,7 +182,7 @@ function scanAssetWidget(
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (!value.trim()) return null
if (typeof value !== 'string' || !value.trim()) return null
if (!isModelFileName(value)) return null
return {
@@ -445,20 +450,68 @@ interface AssetVerifier {
getAssets: (nodeType: string) => AssetItem[] | undefined
}
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
assetsStore?: AssetVerifier
assetsStore?: AssetVerifier,
checkAssetHash: AssetHashVerifier = assetService.checkAssetHash
): Promise<void> {
if (signal?.aborted) return
const pendingCandidates = candidates.filter(
(c) => c.isAssetSupported && c.isMissing === undefined
)
if (pendingCandidates.length === 0) return
const pendingNodeTypes = new Set<string>()
for (const c of candidates) {
if (c.isAssetSupported && c.isMissing === undefined) {
pendingNodeTypes.add(c.nodeType)
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
for (const candidate of pendingCandidates) {
const assetHash = getBlake3AssetHash(candidate)
if (!assetHash) {
pendingNodeTypes.add(candidate.nodeType)
continue
}
const hashCandidates = candidatesByHash.get(assetHash)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(assetHash, [candidate])
}
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
try {
const status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
if (status === 'exists') {
for (const candidate of hashCandidates) {
candidate.isMissing = false
}
return
}
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Model Pipeline] Failed to verify asset hash:',
err
)
}
for (const candidate of hashCandidates) {
pendingNodeTypes.add(candidate.nodeType)
}
})
)
if (signal?.aborted) return
if (pendingNodeTypes.size === 0) return
const store =
@@ -491,6 +544,20 @@ export async function verifyAssetSupportedCandidates(
}
}
function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
if (candidate.hashType?.toLowerCase() !== 'blake3') return null
return toBlake3AssetHash(candidate.hash)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}

View File

@@ -0,0 +1,128 @@
import { createTestingPinia } from '@pinia/testing'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import type * as AxiosModule from 'axios'
import { describe, expect, it, vi } from 'vitest'
import { createApp, effectScope, h } from 'vue'
import { useRemoteOptions } from '@/platform/remote/composables/useRemoteOptions'
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: 'u1',
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
function createTestQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } }
})
}
function withSetup<T>(setup: () => T): { result: T; cleanup: () => void } {
let result!: T
const queryClient = createTestQueryClient()
const app = createApp({
setup() {
result = setup()
return () => h('div')
}
})
app.use(createTestingPinia({ createSpy: vi.fn }))
app.use(VueQueryPlugin, { queryClient })
const container = document.createElement('div')
app.mount(container)
return {
result,
cleanup: () => {
app.unmount()
}
}
}
const desc: RemoteRequestDescriptor = {
client: 'comfyApi',
route: '/test'
}
describe('useRemoteOptions', () => {
it('builds a stable, scope-aware query key', () => {
const key = remoteOptionKeys.byRoute(desc, {
userId: 'u1',
workspaceId: 'w1'
})
expect(key).toContain('comfyApi')
expect(key).toContain('/test')
expect(key).toContain('u1')
expect(key).toContain('w1')
})
it('partitions by route', () => {
const a = remoteOptionKeys.byRoute(
{ client: 'comfyApi', route: '/a' },
{ userId: 'u1', workspaceId: null }
)
const b = remoteOptionKeys.byRoute(
{ client: 'comfyApi', route: '/b' },
{ userId: 'u1', workspaceId: null }
)
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b))
})
it('partitions by workspaceId', () => {
const a = remoteOptionKeys.byRoute(desc, {
userId: 'u1',
workspaceId: 'w1'
})
const b = remoteOptionKeys.byRoute(desc, {
userId: 'u1',
workspaceId: 'w2'
})
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b))
})
it('partitions anonymous from api-key sessions even when userId/workspaceId match', () => {
const anon = remoteOptionKeys.byRoute(desc, {
userId: null,
workspaceId: null,
apiKeyBucket: 'anon'
})
const apikey = remoteOptionKeys.byRoute(desc, {
userId: null,
workspaceId: null,
apiKeyBucket: 'apikey'
})
expect(JSON.stringify(anon)).not.toBe(JSON.stringify(apikey))
})
it('returns disabled state when descriptor is null', async () => {
const scope = effectScope()
let result!: ReturnType<typeof useRemoteOptions>
let cleanup = () => {}
scope.run(() => {
const mounted = withSetup(() =>
useRemoteOptions({
descriptor: null
})
)
result = mounted.result
cleanup = mounted.cleanup
})
expect(result.isLoading.value).toBe(false)
cleanup()
scope.stop()
})
})

View File

@@ -0,0 +1,132 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import axios from 'axios'
import { computed, toValue } from 'vue'
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
import { isRetriableError } from '@/base/remote/retry'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
import type {
RemoteAuthScope,
RemoteRequestDescriptor
} from '@/platform/remote/schema/remoteRequestSchema'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
const DEFAULT_TIMEOUT_MS = 30_000
const DEFAULT_MAX_RETRIES = 3
function resolveUrl(
descriptor: RemoteRequestDescriptor,
baseUrl: string
): string {
if (descriptor.client === 'comfyApi') {
return baseUrl + descriptor.route
}
return descriptor.route
}
async function executeRemoteRequest(
descriptor: RemoteRequestDescriptor,
signal: AbortSignal
): Promise<unknown> {
let headers: Record<string, string> | undefined
if (descriptor.client === 'comfyApi') {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
headers = authHeader ? { ...authHeader } : undefined
}
const url = resolveUrl(descriptor, getComfyApiBaseUrl())
const response = await axios.get(url, {
params: descriptor.params,
timeout: descriptor.timeout ?? DEFAULT_TIMEOUT_MS,
signal,
...(headers ? { headers } : {})
})
return response.data
}
interface UseRemoteOptionsResult<T> {
data: ComputedRef<T | undefined>
rawData: ComputedRef<unknown>
isLoading: ComputedRef<boolean>
isFetching: ComputedRef<boolean>
error: ComputedRef<Error | null>
refetch: () => Promise<unknown>
invalidate: () => Promise<void>
}
interface UseRemoteOptionsArgs<T> {
descriptor: MaybeRefOrGetter<RemoteRequestDescriptor | null | undefined>
enabled?: MaybeRefOrGetter<boolean>
select?: (raw: unknown) => T
}
export function useRemoteOptions<T = unknown>(
args: UseRemoteOptionsArgs<T>
): UseRemoteOptionsResult<T> {
const queryClient = useQueryClient()
const authStore = useAuthStore()
const workspaceStore = useWorkspaceAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const scope = computed<RemoteAuthScope>(() => ({
userId: authStore.userId ?? null,
workspaceId: workspaceStore.currentWorkspace?.id ?? null,
apiKeyBucket: apiKeyStore.getApiKey() ? 'apikey' : 'anon'
}))
const queryKey = computed(() => {
const descriptor = toValue(args.descriptor)
if (!descriptor) {
return [...remoteOptionKeys.all(), 'disabled'] as const
}
return remoteOptionKeys.byRoute(descriptor, scope.value)
})
const enabled = computed(() => {
const userEnabled = toValue(args.enabled)
const hasDescriptor = !!toValue(args.descriptor)
return hasDescriptor && (userEnabled === undefined || userEnabled)
})
const query = useQuery({
queryKey,
enabled,
queryFn: async ({ signal }) => {
const descriptor = toValue(args.descriptor)
if (!descriptor) {
throw new Error('useRemoteOptions: descriptor is required')
}
return executeRemoteRequest(descriptor, signal)
},
retry: (failureCount, error) => {
const descriptor = toValue(args.descriptor)
const max = descriptor?.maxRetries ?? DEFAULT_MAX_RETRIES
return failureCount < max && isRetriableError(error)
},
staleTime: computed(() => toValue(args.descriptor)?.ttl ?? 0)
})
const data = computed<T | undefined>(() => {
const raw = query.data.value
if (raw === undefined) return undefined
if (args.select) return args.select(raw)
return raw as T
})
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
return {
data,
rawData: computed(() => query.data.value),
isLoading: computed(() => query.isLoading.value),
isFetching: computed(() => query.isFetching.value),
error: computed(() => query.error.value),
refetch: () => query.refetch(),
invalidate
}
}

View File

@@ -0,0 +1,43 @@
import { QueryClient } from '@tanstack/vue-query'
import { isRetriableError } from '@/base/remote/retry'
const DEFAULT_GC_TIME_MS = 5 * 60_000
const DEFAULT_RETRY_COUNT = 3
let appQueryClient: QueryClient | undefined
/**
* Create the application-wide TanStack Query client.
*
* Defaults are tuned for remote-option dropdowns and similar widget data:
* - `staleTime: 0` so refresh buttons always re-fetch
* - `gcTime` bounded so a session's footprint stays small (no LRU yet)
* - `retry` driven by {@link isRetriableError} from `base/remote/retry`
* - `refetchOnWindowFocus: false` to avoid surprise re-fetches mid-edit
*
* QueryClient lifetime is bound to the Vue app instance; auth-state changes
* tear down the authenticated layout subtree (see master plan §8), so the
* cache is naturally evicted without manual `queryClient.clear()` calls.
*/
export function createAppQueryClient(): QueryClient {
appQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
gcTime: DEFAULT_GC_TIME_MS,
retry: (failureCount, error) =>
failureCount < DEFAULT_RETRY_COUNT && isRetriableError(error),
refetchOnWindowFocus: false
}
}
})
return appQueryClient
}
export function getAppQueryClient(): QueryClient {
if (!appQueryClient) {
appQueryClient = createAppQueryClient()
}
return appQueryClient
}

View File

@@ -0,0 +1,26 @@
import type {
RemoteAuthScope,
RemoteRequestDescriptor
} from '@/platform/remote/schema/remoteRequestSchema'
function sortedParams(
params?: Record<string, string>
): Array<[string, string]> {
if (!params) return []
return Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
}
export const remoteOptionKeys = {
all: () => ['remote-options'] as const,
byRoute: (descriptor: RemoteRequestDescriptor, scope: RemoteAuthScope) =>
[
...remoteOptionKeys.all(),
descriptor.client,
descriptor.route,
descriptor.responseKey ?? '',
sortedParams(descriptor.params),
scope.workspaceId ?? null,
scope.userId ?? null,
scope.apiKeyBucket ?? null
] as const
}

View File

@@ -0,0 +1,19 @@
export type RemoteRequestClient = 'comfyApi'
export interface RemoteRequestDescriptor {
client: RemoteRequestClient
route: string
params?: Record<string, string>
responseKey?: string
ttl?: number
timeout?: number
maxRetries?: number
}
export type RemoteAuthBucket = 'apikey' | 'anon'
export interface RemoteAuthScope {
userId?: string | null
workspaceId?: string | null
apiKeyBucket?: RemoteAuthBucket | null
}

View File

@@ -11,6 +11,12 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
triggerThreshold: 3,
delayMs: 5000
},
'queue-progress-overlay': {
featureId: 'queue-progress-overlay',
typeformId: 'HZ5saxry',
triggerThreshold: 16,
delayMs: 5000
},
'error-panel': {
featureId: 'error-panel',
typeformId: 'iFp4p4mV',

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { comboAdapter } from '@/renderer/extensions/vueNodes/widgets/adapters/comboAdapter'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
function makeSpec(overrides: Partial<ComboInputSpec> = {}): ComboInputSpec {
return {
name: 'field',
type: 'COMBO',
isOptional: false,
...overrides
} as ComboInputSpec
}
describe('comboAdapter.canHandle', () => {
it('returns true for combo input specs', () => {
expect(comboAdapter.canHandle(makeSpec())).toBe(true)
})
})
describe('comboAdapter.extractProps', () => {
it('returns kind=unknown when no upload flags set', () => {
expect(comboAdapter.extractProps(makeSpec()).assetKind).toBe('unknown')
})
it('detects video', () => {
expect(
comboAdapter.extractProps(makeSpec({ video_upload: true })).assetKind
).toBe('video')
})
it('detects image (image_upload)', () => {
expect(
comboAdapter.extractProps(makeSpec({ image_upload: true })).assetKind
).toBe('image')
})
it('detects image (animated_image_upload)', () => {
expect(
comboAdapter.extractProps(makeSpec({ animated_image_upload: true }))
.assetKind
).toBe('image')
})
it('detects audio', () => {
expect(
comboAdapter.extractProps(makeSpec({ audio_upload: true })).assetKind
).toBe('audio')
})
it('detects mesh and forces uploadFolder=input', () => {
const props = comboAdapter.extractProps(makeSpec({ mesh_upload: true }))
expect(props.assetKind).toBe('mesh')
expect(props.uploadFolder).toBe('input')
})
it('respects image_folder for non-mesh', () => {
const props = comboAdapter.extractProps(
makeSpec({ image_upload: true, image_folder: 'output' })
)
expect(props.uploadFolder).toBe('output')
})
it('flags allowUpload when any *_upload is true', () => {
expect(
comboAdapter.extractProps(makeSpec({ image_upload: true })).allowUpload
).toBe(true)
expect(comboAdapter.extractProps(makeSpec()).allowUpload).toBe(false)
})
})

View File

@@ -0,0 +1,31 @@
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { AssetKind } from '@/types/widgetTypes'
import type { SpecAdapter, SpecAdapterProps } from './specAdapter'
function deriveAssetKind(spec: ComboInputSpec): AssetKind {
if (spec.video_upload) return 'video'
if (spec.image_upload || spec.animated_image_upload) return 'image'
if (spec.audio_upload) return 'audio'
if (spec.mesh_upload) return 'mesh'
return 'unknown'
}
export const comboAdapter: SpecAdapter<ComboInputSpec> = {
canHandle: isComboInputSpec,
extractProps: (spec): SpecAdapterProps => {
const allowUpload =
spec.image_upload === true ||
spec.animated_image_upload === true ||
spec.video_upload === true ||
spec.audio_upload === true ||
spec.mesh_upload === true
return {
assetKind: deriveAssetKind(spec),
allowUpload,
uploadFolder: spec.mesh_upload ? 'input' : spec.image_folder,
uploadSubfolder: spec.upload_subfolder
}
}
}

View File

@@ -0,0 +1,18 @@
import type { Component } from 'vue'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { AssetKind } from '@/types/widgetTypes'
export interface SpecAdapterProps {
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
uploadSubfolder?: string
}
export interface SpecAdapter<T extends InputSpec> {
canHandle: (spec: InputSpec) => spec is T
extractProps: (spec: T) => SpecAdapterProps
component?: Component
}

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { ComboboxContent, ComboboxPortal } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { contentVariants } from './remoteCombo.variants'
defineProps<{
class?: string
}>()
</script>
<template>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="4"
align="start"
:class="cn(contentVariants(), $props.class)"
data-testid="remote-combo-content"
>
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ComboboxEmpty } from 'reka-ui'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<ComboboxEmpty
class="p-3 text-center text-xs text-muted-foreground"
aria-live="polite"
data-testid="remote-combo-empty"
>
<slot>
{{ t('widgets.remoteCombo.noResults') }}
</slot>
</ComboboxEmpty>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { inject } from 'vue'
defineProps<{
message?: string
}>()
import { RemoteComboKey } from './state'
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Error must be used inside RemoteCombo.Root')
}
</script>
<template>
<div
class="flex items-center gap-2 rounded-sm bg-destructive-background/10 px-3 py-2 text-xs text-base-foreground"
role="alert"
aria-live="assertive"
data-testid="remote-combo-error"
>
<i
class="icon-[lucide--alert-circle] size-4 shrink-0 text-destructive-background"
aria-hidden="true"
/>
<span class="flex-1">{{ message ?? ctx.errorMessage.value }}</span>
</div>
</template>

View File

@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { ComboboxRoot } from 'reka-ui'
import { computed, defineComponent, h, provide, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import Item from './Item.vue'
import { RemoteComboKey } from './state'
import type { RemoteComboContext, RemoteComboPreviewType } from './state'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
widgets: {
remoteCombo: {
playAudioPreview: 'Play audio preview',
pauseAudioPreview: 'Pause audio preview'
}
}
}
}
})
function makeCtx(previewType: RemoteComboPreviewType): RemoteComboContext {
return {
isOpen: ref(true),
searchQuery: ref(''),
selectedValue: ref<string | undefined>(undefined),
items: computed(() => []),
filteredItems: computed(() => []),
isLoading: computed(() => false),
isFetching: computed(() => false),
errorMessage: computed(() => null),
refresh: async () => {},
select: () => {},
fieldLabel: computed(() => 'field'),
previewType: computed(() => previewType)
}
}
function renderItemInOpenCombobox(
item: DropdownItemShape,
previewType: RemoteComboPreviewType
) {
const Host = defineComponent({
setup() {
provide(RemoteComboKey, makeCtx(previewType))
return () =>
h(
ComboboxRoot,
{ open: true, modelValue: undefined },
{
default: () => h(Item, { item, index: 0 })
}
)
}
})
return render(Host, { global: { plugins: [i18n] } })
}
describe('RemoteCombo.Item preview rendering', () => {
it('renders an <img> for image preview_type with preview_url', () => {
renderItemInOpenCombobox(
{
id: '1',
name: 'Picture',
preview_url: 'https://cdn.example.com/p.png'
},
'image'
)
const img = screen.getByRole('img', { name: /picture/i })
expect(img).toHaveAttribute('src', 'https://cdn.example.com/p.png')
})
it('renders an audio play button for audio preview_type with preview_url', () => {
renderItemInOpenCombobox(
{ id: '1', name: 'Voice', preview_url: 'https://cdn.example.com/a.mp3' },
'audio'
)
expect(
screen.getByRole('button', { name: /play audio preview/i })
).toBeInTheDocument()
})
it('omits preview element when preview_url is missing', () => {
renderItemInOpenCombobox({ id: '1', name: 'NoPreview' }, 'image')
expect(screen.queryByRole('img')).toBeNull()
})
})

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { ComboboxItem, ComboboxItemIndicator } from 'reka-ui'
import { computed, inject, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { displayName } from '@/base/remote/itemSchema'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import { itemVariants } from './remoteCombo.variants'
import type { ItemVariants } from './remoteCombo.variants'
import { RemoteComboKey } from './state'
const props = defineProps<{
item: DropdownItemShape
index: number
layout?: ItemVariants['layout']
class?: string
}>()
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Item must be used inside RemoteCombo.Root')
}
const { t } = useI18n()
const isSelected = computed(() => ctx.selectedValue.value === props.item.id)
const hasPreview = computed(() => !!props.item.preview_url)
const label = computed(() => displayName(props.item))
const audioEl = useTemplateRef<HTMLAudioElement>('audioEl')
const isPlaying = ref(false)
function toggleAudio() {
const el = audioEl.value
if (!el) return
if (el.paused) {
void el.play().then(() => {
isPlaying.value = true
})
} else {
el.pause()
isPlaying.value = false
}
}
function handleAudioEnded() {
isPlaying.value = false
}
</script>
<template>
<ComboboxItem
:value="item.id"
:class="cn(itemVariants({ layout: props.layout }), props.class)"
:data-testid="`remote-combo-item-${index}`"
@select="ctx.select(item.id)"
>
<slot :item="item" :index="index" :is-selected="isSelected">
<template v-if="hasPreview && ctx.previewType.value === 'image'">
<img
:src="item.preview_url"
:alt="label"
class="size-10 shrink-0 rounded-sm object-cover"
loading="lazy"
decoding="async"
/>
</template>
<template v-else-if="hasPreview && ctx.previewType.value === 'video'">
<video
:src="item.preview_url"
:aria-label="label"
class="size-10 shrink-0 rounded-sm object-cover"
preload="metadata"
muted
playsinline
/>
</template>
<template v-else-if="hasPreview && ctx.previewType.value === 'audio'">
<button
type="button"
class="focus-visible:ring-ring flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover text-base-foreground hover:bg-secondary-background-selected focus-visible:ring-1 focus-visible:outline-none"
:aria-label="
isPlaying
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlaying"
@click.stop="toggleAudio"
@pointerdown.stop
>
<i
:class="
cn(
'size-4',
isPlaying ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
aria-hidden="true"
/>
<audio
ref="audioEl"
:src="item.preview_url"
preload="none"
class="sr-only"
@ended="handleAudioEnded"
/>
</button>
</template>
<div class="flex flex-1 flex-col gap-0.5 overflow-hidden">
<span class="truncate">{{ label }}</span>
<span
v-if="item.description"
class="truncate text-[10px] text-muted-foreground"
>
{{ item.description }}
</span>
</div>
</slot>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] size-4 text-primary-background"
aria-hidden="true"
/>
</ComboboxItemIndicator>
</ComboboxItem>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
const props = defineProps<{
layout?: LayoutMode
}>()
const layoutMode = defineModel<LayoutMode>('layout', { default: 'list' })
void props
const { t } = useI18n()
function setLayout(mode: LayoutMode) {
layoutMode.value = mode
}
</script>
<template>
<div
class="flex items-center gap-1"
role="group"
:aria-label="t('widgets.remoteCombo.layoutSwitcherAriaLabel')"
data-testid="remote-combo-layout-switcher"
>
<Button
variant="textonly"
size="icon-sm"
type="button"
:aria-label="t('widgets.remoteCombo.layoutList')"
:aria-pressed="layoutMode === 'list'"
:class="cn(layoutMode === 'list' && 'bg-secondary-background-selected')"
@click.stop="setLayout('list')"
>
<i class="icon-[lucide--list] size-4" aria-hidden="true" />
</Button>
<Button
variant="textonly"
size="icon-sm"
type="button"
:aria-label="t('widgets.remoteCombo.layoutGrid')"
:aria-pressed="layoutMode === 'grid'"
:class="cn(layoutMode === 'grid' && 'bg-secondary-background-selected')"
@click.stop="setLayout('grid')"
>
<i class="icon-[lucide--grid-2x2] size-4" aria-hidden="true" />
</Button>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { ComboboxViewport } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { listVariants } from './remoteCombo.variants'
defineProps<{
class?: string
}>()
</script>
<template>
<ComboboxViewport
:class="cn(listVariants(), $props.class)"
data-testid="remote-combo-list"
>
<slot />
</ComboboxViewport>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground"
role="status"
aria-live="polite"
aria-busy="true"
data-testid="remote-combo-loading"
>
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-primary-background"
aria-hidden="true"
/>
<span>{{ t('widgets.remoteCombo.loading') }}</span>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import { RemoteComboKey } from './state'
import type { RemoteComboContext } from './state'
const props = defineProps<{
class?: string
context?: RemoteComboContext
disabled?: boolean
}>()
const injected = inject(RemoteComboKey, null)
const resolved = props.context ?? injected
if (!resolved) {
throw new Error(
'RemoteCombo.Refresh requires a RemoteComboContext (provide via Root or pass as prop)'
)
}
const ctx = resolved
const { t } = useI18n()
async function handleClick() {
await ctx.refresh()
}
</script>
<template>
<Button
variant="textonly"
size="icon"
type="button"
:disabled="props.disabled"
:aria-label="t('widgets.remoteCombo.refresh')"
:title="t('widgets.remoteCombo.refresh')"
:class="cn('shrink-0', props.class)"
data-testid="remote-combo-refresh"
@click.stop="handleClick"
>
<i
:class="
cn(
'icon-[lucide--rotate-cw] size-4',
ctx.isFetching.value && 'animate-spin'
)
"
aria-hidden="true"
/>
</Button>
</template>

View File

@@ -0,0 +1,164 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import Content from './Content.vue'
import Empty from './Empty.vue'
import ErrorAtom from './Error.vue'
import Item from './Item.vue'
import LayoutSwitcher from './LayoutSwitcher.vue'
import List from './List.vue'
import Loading from './Loading.vue'
import Refresh from './Refresh.vue'
import Root from './Root.vue'
import Search from './Search.vue'
import Trigger from './Trigger.vue'
import type { RemoteComboContext } from './state'
const sampleItems: DropdownItemShape[] = [
{ id: 'voice-1', name: 'Aria', description: 'Soft, warm female voice' },
{ id: 'voice-2', name: 'Roger', description: 'Deep, narrator male voice' },
{ id: 'voice-3', name: 'Sarah', description: 'Bright, youthful' },
{ id: 'voice-4', name: 'Charlie', description: 'Calm, professional' },
{ id: 'voice-5', name: 'George', description: 'Casual, friendly' }
]
interface StoryArgs {
isLoading: boolean
hasError: boolean
items: DropdownItemShape[]
selected?: string
}
function makeContext(args: StoryArgs): RemoteComboContext {
const isOpen = ref(false)
const searchQuery = ref('')
const selectedValue = ref(args.selected) as Ref<string | undefined>
const items = computed(() => args.items)
const filteredItems = computed(() =>
searchQuery.value
? items.value.filter((it) =>
it.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
: items.value
)
return {
isOpen,
searchQuery,
selectedValue,
items,
filteredItems,
isLoading: computed(() => args.isLoading),
isFetching: computed(() => args.isLoading),
errorMessage: computed(() =>
args.hasError ? 'Failed to load options' : null
),
refresh: async () => {},
select: (id) => {
selectedValue.value = id
isOpen.value = false
},
fieldLabel: computed(() => 'voice'),
previewType: computed(() => 'image' as const)
}
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/RemoteCombo',
argTypes: {
isLoading: { control: 'boolean' },
hasError: { control: 'boolean' }
},
args: {
isLoading: false,
hasError: false,
items: sampleItems,
selected: undefined
},
parameters: {
docs: {
description: {
component:
'Atomized remote-populated combo widget. Compose Root → Trigger + Content (Search, List/Item, Loading, Empty, Error) and an optional Refresh sibling.'
}
}
}
}
export default meta
type Story = StoryObj<StoryArgs>
const renderTemplate = (args: StoryArgs) => ({
components: {
Root,
Trigger,
Content,
Search,
List,
Item,
Empty,
Loading,
ErrorAtom,
Refresh,
LayoutSwitcher
},
setup() {
const ctx = makeContext(args)
return { ctx, args }
},
template: `
<div class="flex w-72 items-center gap-1">
<Root :context="ctx" class="min-w-0 flex-1">
<Trigger class="min-w-0 flex-1" />
<Content>
<Search />
<Loading v-if="args.isLoading" />
<ErrorAtom v-else-if="args.hasError" />
<List v-else>
<Item v-for="(item, index) in ctx.filteredItems.value" :key="item.id" :item="item" :index="index" />
<Empty v-if="ctx.filteredItems.value.length === 0" />
</List>
</Content>
</Root>
<Refresh :context="ctx" />
</div>
`
})
export const Default: Story = {
render: renderTemplate
}
export const LoadingState: Story = {
args: { isLoading: true, items: [] },
render: renderTemplate
}
export const ErrorState: Story = {
args: { hasError: true, items: [] },
render: renderTemplate
}
export const EmptyState: Story = {
args: { items: [] },
render: renderTemplate
}
export const WithSelection: Story = {
args: { selected: 'voice-2' },
render: renderTemplate
}
export const KeyboardA11y: Story = {
parameters: {
docs: {
description: {
story:
'Tab to focus trigger; Enter/Space opens; Arrow keys navigate; Enter selects; Escape closes. Demonstrates the reka-ui Combobox keyboard contract.'
}
}
},
render: renderTemplate
}

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ComboboxRoot } from 'reka-ui'
import { provide } from 'vue'
import { RemoteComboKey } from './state'
import type { RemoteComboContext } from './state'
const props = defineProps<{
context: RemoteComboContext
multiple?: boolean
disabled?: boolean
}>()
const ctx = props.context
provide(RemoteComboKey, ctx)
function onOpenChange(value: boolean) {
ctx.isOpen.value = value
}
function onSearchChange(value: string) {
ctx.searchQuery.value = value
}
</script>
<template>
<ComboboxRoot
:open="ctx.isOpen.value"
:search-term="ctx.searchQuery.value"
:multiple="multiple"
:disabled="disabled"
ignore-filter
:reset-search-term-on-select="false"
data-testid="remote-combo-root"
@update:open="onOpenChange"
@update:search-term="onSearchChange"
>
<slot />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ComboboxInput } from 'reka-ui'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { searchVariants } from './remoteCombo.variants'
import { RemoteComboKey } from './state'
defineProps<{
placeholder?: string
}>()
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Search must be used inside RemoteCombo.Root')
}
const { t } = useI18n()
const emptyDisplayValue = () => ''
</script>
<template>
<div :class="cn(searchVariants())">
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<ComboboxInput
v-model="ctx.searchQuery.value"
:display-value="emptyDisplayValue"
:placeholder="placeholder ?? t('g.search')"
class="w-full border-none bg-transparent text-xs text-base-foreground outline-none placeholder:text-muted-foreground"
:aria-label="
t('widgets.remoteCombo.searchAriaLabel', {
field: ctx.fieldLabel.value
})
"
data-testid="remote-combo-search-input"
/>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ComboboxAnchor, ComboboxTrigger } from 'reka-ui'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { displayName } from '@/base/remote/itemSchema'
import { triggerVariants } from './remoteCombo.variants'
import type { TriggerVariants } from './remoteCombo.variants'
import { RemoteComboKey } from './state'
const props = defineProps<{
size?: TriggerVariants['size']
variant?: TriggerVariants['variant']
border?: TriggerVariants['border']
class?: string
placeholder?: string
disabled?: boolean
}>()
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Trigger must be used inside RemoteCombo.Root')
}
const { t } = useI18n()
const displayLabel = computed(() => {
if (ctx.isLoading.value) return t('widgets.remoteCombo.loading')
if (ctx.errorMessage.value) return ctx.errorMessage.value
const id = ctx.selectedValue.value
if (!id) return props.placeholder ?? t('widgets.uploadSelect.placeholder')
const item = ctx.items.value.find((i) => i.id === id)
return item ? displayName(item) : id
})
const computedBorder = computed<TriggerVariants['border']>(() => {
if (props.border) return props.border
if (ctx.errorMessage.value) return 'invalid'
if (ctx.isOpen.value) return 'active'
return 'none'
})
</script>
<template>
<ComboboxAnchor as-child>
<ComboboxTrigger
:class="
cn(
triggerVariants({
size: props.size,
variant: props.variant,
border: computedBorder
}),
props.class
)
"
:aria-label="
t('widgets.remoteCombo.selectAriaLabel', {
field: ctx.fieldLabel.value
})
"
:disabled="
props.disabled || ctx.isLoading.value || !!ctx.errorMessage.value
"
:aria-disabled="
props.disabled || ctx.isLoading.value || !!ctx.errorMessage.value
"
data-testid="remote-combo-trigger"
>
<span class="truncate">{{ displayLabel }}</span>
<i
class="icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</ComboboxTrigger>
</ComboboxAnchor>
</template>

View File

@@ -0,0 +1,61 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const triggerVariants = cva({
base: 'relative inline-flex w-full items-center justify-between gap-2 cursor-pointer select-none rounded-md border border-border-default bg-secondary-background text-base-foreground transition-colors hover:bg-secondary-background-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none',
variants: {
size: {
sm: 'h-6 px-2 text-xs',
md: 'h-8 px-3 text-xs',
lg: 'h-10 px-4 text-sm'
},
variant: {
secondary: 'bg-secondary-background hover:bg-secondary-background-hover',
primary:
'bg-primary-background text-base-foreground hover:bg-primary-background-hover',
destructive:
'bg-destructive-background text-base-foreground hover:bg-destructive-background-hover',
textonly:
'border-transparent bg-transparent hover:bg-secondary-background-hover'
},
border: {
none: '',
active: 'border-node-component-border',
invalid: 'border-destructive-background'
}
},
defaultVariants: {
size: 'md',
variant: 'secondary',
border: 'none'
}
})
export type TriggerVariants = VariantProps<typeof triggerVariants>
export const contentVariants = cva({
base: 'z-50 min-w-(--reka-combobox-trigger-width) overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground shadow-md data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2'
})
export const itemVariants = cva({
base: 'relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-xs text-base-foreground outline-none transition-colors hover:bg-secondary-background-hover data-highlighted:bg-secondary-background-selected data-[state=checked]:bg-secondary-background-selected data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
variants: {
layout: {
single: 'rounded-sm',
multi: 'gap-2 rounded-sm'
}
},
defaultVariants: {
layout: 'single'
}
})
export type ItemVariants = VariantProps<typeof itemVariants>
export const searchVariants = cva({
base: 'flex w-full items-center gap-2 border-b border-border-default px-3 py-1.5'
})
export const listVariants = cva({
base: 'flex max-h-[16rem] flex-col gap-0 overflow-y-auto p-1 text-xs scrollbar-custom'
})

View File

@@ -0,0 +1,23 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
export type RemoteComboPreviewType = 'image' | 'video' | 'audio'
export interface RemoteComboContext {
isOpen: Ref<boolean>
searchQuery: Ref<string>
selectedValue: Ref<string | undefined>
items: ComputedRef<DropdownItemShape[]>
filteredItems: ComputedRef<DropdownItemShape[]>
isLoading: ComputedRef<boolean>
isFetching: ComputedRef<boolean>
errorMessage: ComputedRef<string | null>
refresh: () => Promise<void>
select: (id: string) => void
fieldLabel: ComputedRef<string>
previewType: ComputedRef<RemoteComboPreviewType>
}
export const RemoteComboKey: InjectionKey<RemoteComboContext> =
Symbol('RemoteComboContext')

View File

@@ -0,0 +1,187 @@
import { createTestingPinia } from '@pinia/testing'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { render, screen, waitFor } from '@testing-library/vue'
import axios from 'axios'
import type * as AxiosModule from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: undefined,
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
widgets: {
remoteCombo: {
loading: 'Loading...',
loadFailed: 'Failed to load options',
noResults: 'No results found',
refresh: 'Refresh options',
selectAriaLabel: 'Select {field}',
searchAriaLabel: 'Search {field}',
layoutSwitcherAriaLabel: 'Layout switcher',
layoutList: 'List view',
layoutGrid: 'Grid view'
},
uploadSelect: { placeholder: 'Select...' }
},
g: { search: 'Search' }
}
}
})
function makeWidget(
spec: ComboInputSpec,
value: string | undefined = undefined
): SimplifiedWidget<string | undefined> {
return createMockWidget({
name: 'remote_field',
type: 'combo',
value,
spec
}) as SimplifiedWidget<string | undefined>
}
const itemSchema: RemoteItemSchema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
function makeRemoteCombo(
overrides: Partial<RemoteComboConfig> = {}
): ComboInputSpec {
return {
name: 'remote_field',
type: 'COMBO',
isOptional: false,
remote_combo: {
route: '/test/options',
item_schema: itemSchema,
...overrides
}
}
}
function renderWithProviders(
component: typeof RichComboWidget,
props: { widget: SimplifiedWidget<string | undefined> }
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
return render(component, {
global: {
plugins: [
i18n,
createTestingPinia({ createSpy: vi.fn }),
[VueQueryPlugin, { queryClient }]
]
},
props
})
}
beforeEach(() => {
vi.mocked(axios.get).mockReset()
})
describe('RichComboWidget', () => {
it('renders trigger with placeholder when no selection and no items loaded', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
const widget = makeWidget(makeRemoteCombo())
renderWithProviders(RichComboWidget, { widget })
expect(screen.getByTestId('remote-combo-trigger')).toBeInTheDocument()
})
it('shows loading state while fetching', async () => {
let resolveResp: (value: unknown) => void = () => {}
vi.mocked(axios.get).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveResp = (data) => resolve({ data, status: 200 } as never)
})
)
const widget = makeWidget(makeRemoteCombo())
renderWithProviders(RichComboWidget, { widget })
const trigger = await screen.findByTestId('remote-combo-trigger')
expect(trigger).toHaveTextContent(/loading/i)
expect(trigger).toHaveAttribute('aria-disabled', 'true')
resolveResp([])
})
it('auto_select="first" selects first item when value is empty', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: [
{ id: 'one', name: 'One' },
{ id: 'two', name: 'Two' }
],
status: 200
})
const widget = makeWidget(makeRemoteCombo({ auto_select: 'first' }))
const { emitted } = renderWithProviders(RichComboWidget, { widget })
await waitFor(() => {
const events = emitted<unknown[]>('update:modelValue')
expect(events?.[0]?.[0]).toBe('one')
})
})
it('auto_select="last" selects last item when value is empty', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
],
status: 200
})
const widget = makeWidget(makeRemoteCombo({ auto_select: 'last' }))
const { emitted } = renderWithProviders(RichComboWidget, { widget })
await waitFor(() => {
const events = emitted<unknown[]>('update:modelValue')
expect(events?.[0]?.[0]).toBe('c')
})
})
it('renders refresh button when refresh_button is undefined', () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
const widget = makeWidget(makeRemoteCombo())
renderWithProviders(RichComboWidget, { widget })
expect(screen.getByTestId('remote-combo-refresh')).toBeInTheDocument()
})
it('hides refresh button when refresh_button is false', () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
const widget = makeWidget(makeRemoteCombo({ refresh_button: false }))
renderWithProviders(RichComboWidget, { widget })
expect(screen.queryByTestId('remote-combo-refresh')).toBeNull()
})
})

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import RemoteComboContent from './RemoteCombo/Content.vue'
import RemoteComboEmpty from './RemoteCombo/Empty.vue'
import RemoteComboError from './RemoteCombo/Error.vue'
import RemoteComboItem from './RemoteCombo/Item.vue'
import RemoteComboList from './RemoteCombo/List.vue'
import RemoteComboLoading from './RemoteCombo/Loading.vue'
import RemoteComboRefresh from './RemoteCombo/Refresh.vue'
import RemoteComboRoot from './RemoteCombo/Root.vue'
import RemoteComboSearch from './RemoteCombo/Search.vue'
import RemoteComboTrigger from './RemoteCombo/Trigger.vue'
import type { RemoteComboContext } from './RemoteCombo/state'
import { useRemoteCombo } from '../composables/useRemoteCombo'
const { widget } = defineProps<{
widget: SimplifiedWidget<string | undefined>
}>()
const modelValue = defineModel<string | undefined>()
const comboSpec = computed(() => {
if (widget.spec && isComboInputSpec(widget.spec)) {
return widget.spec
}
return undefined
})
const remoteConfig = computed<RemoteComboConfig | undefined>(
() => comboSpec.value?.remote_combo
)
const fieldLabel = computed(() => widget.label ?? widget.name)
const combo = useRemoteCombo({
config: remoteConfig,
modelValue,
fieldLabel
})
const context: RemoteComboContext = {
isOpen: combo.isOpen,
searchQuery: combo.searchQuery,
selectedValue: combo.selectedValue,
items: combo.items,
filteredItems: combo.filteredItems,
isLoading: combo.isLoading,
isFetching: combo.isFetching,
errorMessage: combo.errorMessage,
refresh: combo.refresh,
select: combo.select,
fieldLabel: combo.fieldLabel,
previewType: combo.previewType
}
const showRefreshButton = computed(
() => !!remoteConfig.value && remoteConfig.value.refresh_button !== false
)
const isDisabled = computed(() => widget.options?.disabled === true)
</script>
<template>
<div
class="flex w-full min-w-0 items-center gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<RemoteComboRoot
:context="context"
:disabled="isDisabled"
class="min-w-0 flex-1"
>
<RemoteComboTrigger :disabled="isDisabled" class="min-w-0 flex-1" />
<RemoteComboContent>
<RemoteComboSearch />
<RemoteComboLoading v-if="combo.isLoading.value" />
<RemoteComboError v-else-if="combo.errorMessage.value" />
<RemoteComboList v-else>
<RemoteComboItem
v-for="(item, index) in combo.filteredItems.value"
:key="item.id"
:item
:index
/>
<RemoteComboEmpty v-if="combo.filteredItems.value.length === 0" />
</RemoteComboList>
</RemoteComboContent>
</RemoteComboRoot>
<RemoteComboRefresh
v-if="showRefreshButton"
:context="context"
:disabled="isDisabled"
/>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<template>
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-else-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -24,6 +25,7 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -53,6 +55,8 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -33,6 +33,8 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -59,6 +61,8 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -229,6 +233,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -68,7 +68,11 @@ const theButtonStyle = computed(() =>
{{ placeholder }}
</span>
<span v-else>
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
{{
selectedItems
.map((item) => item.label || item.name || item.id)
.join(', ')
}}
</span>
</span>
<i

View File

@@ -20,6 +20,8 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -31,6 +33,8 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -112,6 +116,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -145,6 +151,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -16,8 +16,10 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
defineProps<{
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -112,6 +114,7 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
:aria-label="t('assetBrowser.sortBy')"
:title="t('assetBrowser.sortBy')"
@@ -132,6 +135,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -306,6 +310,7 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -28,11 +28,15 @@ const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isMesh = computed(() => assetKind?.value === 'mesh')
const isAudio = computed(() => assetKind?.value === 'audio')
const mediaContainerRef = ref<HTMLElement>()
const resolvedMeshPreview = ref<string | null>(null)
const meshPreviewAttempted = ref(false)
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function toLookupName(name: string): string {
const stripped = name.replace(/ \[output\]$/, '')
const slash = stripped.lastIndexOf('/')
@@ -68,6 +72,17 @@ function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
const audio = audioRef.value
if (!audio) return
if (audio.paused) {
void audio.play().catch(() => {})
} else {
audio.pause()
}
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -148,6 +163,35 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<button
v-else-if="previewUrl && isAudio"
type="button"
:aria-label="
isPlayingAudio
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlayingAudio"
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@play="isPlayingAudio = true"
@pause="isPlayingAudio = false"
@ended="isPlayingAudio = false"
/>
<i
:class="
cn(
'text-secondary size-5',
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
/>
</button>
<img
v-else-if="displayedPreviewUrl"
:src="displayedPreviewUrl"
@@ -193,6 +237,13 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,7 +12,9 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Preview image/video URL */
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean
@@ -47,6 +49,7 @@ export interface FormDropdownMenuItemProps {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}

View File

@@ -0,0 +1,163 @@
import { computed, ref, toValue, watch } from 'vue'
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem
} from '@/base/remote/itemSchema'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useRemoteOptions } from '@/platform/remote/composables/useRemoteOptions'
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import type { RemoteComboPreviewType } from '../components/RemoteCombo/state'
interface UseRemoteComboArgs {
config: MaybeRefOrGetter<RemoteComboConfig | undefined | null>
modelValue: Ref<string | undefined>
fieldLabel?: MaybeRefOrGetter<string>
enabled?: MaybeRefOrGetter<boolean>
}
interface UseRemoteComboResult {
isOpen: Ref<boolean>
searchQuery: Ref<string>
items: ComputedRef<DropdownItemShape[]>
filteredItems: ComputedRef<DropdownItemShape[]>
isLoading: ComputedRef<boolean>
isFetching: ComputedRef<boolean>
errorMessage: ComputedRef<string | null>
refresh: () => Promise<void>
select: (id: string) => void
selectedValue: Ref<string | undefined>
fieldLabel: ComputedRef<string>
previewType: ComputedRef<RemoteComboPreviewType>
}
export function useRemoteCombo(args: UseRemoteComboArgs): UseRemoteComboResult {
const { t } = useI18n()
const isOpen = ref(false)
const searchQuery = ref('')
const descriptor = computed<RemoteRequestDescriptor | null>(() => {
const config = toValue(args.config)
if (!config) return null
return {
client: 'comfyApi',
route: config.route,
responseKey: config.response_key,
ttl: config.refresh,
timeout: config.timeout,
maxRetries: config.max_retries
}
})
const { rawData, isLoading, isFetching, error, refetch } = useRemoteOptions({
descriptor,
enabled: args.enabled
})
const rawItems = computed<unknown[]>(() => {
const data = rawData.value
const config = toValue(args.config)
if (data === undefined) return []
const items = extractItems(data, config?.response_key)
return items ?? []
})
const items = computed<DropdownItemShape[]>(() => {
const config = toValue(args.config)
const schema = config?.item_schema
if (schema) {
const previewBaseUrl = getComfyApiBaseUrl()
return rawItems.value.map((raw) =>
mapToDropdownItem(raw, schema, { previewBaseUrl })
)
}
return rawItems.value.map((raw) => {
const val = String(raw ?? '')
return { id: val, name: val }
})
})
const searchIndex = computed(() => {
const config = toValue(args.config)
const schema = config?.item_schema
const fields = schema?.search_fields
if (!schema || !fields?.length) return new Map<string, string>()
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
const text = buildSearchText(raw, fields)
if (text) index.set(id, text)
}
return index
})
const filteredItems = computed<DropdownItemShape[]>(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return items.value
return items.value.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
})
const errorMessage = computed<string | null>(() => {
if (!error.value) return null
return t('widgets.remoteCombo.loadFailed')
})
const fieldLabel = computed(() => toValue(args.fieldLabel) ?? '')
const previewType = computed<RemoteComboPreviewType>(
() => toValue(args.config)?.item_schema?.preview_type ?? 'image'
)
function applyAutoSelect(config: RemoteComboConfig) {
if (args.modelValue.value) return
const list = items.value
if (list.length === 0) return
if (config.auto_select === 'first') {
args.modelValue.value = list[0].id
} else if (config.auto_select === 'last') {
args.modelValue.value = list[list.length - 1].id
}
}
watch(
items,
() => {
const config = toValue(args.config)
if (config) applyAutoSelect(config)
},
{ immediate: true }
)
async function refresh() {
await refetch()
}
function select(id: string) {
args.modelValue.value = id
isOpen.value = false
}
return {
isOpen,
searchQuery,
items,
filteredItems,
isLoading,
isFetching,
errorMessage,
refresh,
select,
selectedValue: args.modelValue,
fieldLabel,
previewType
}
}

View File

@@ -1,753 +0,0 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
function createMockWidget(overrides: Partial<IWidget> = {}): IWidget {
return {
name: 'test_widget',
type: 'text',
value: '',
options: {},
...overrides
} as Partial<IWidget> as IWidget
}
const mockCloudAuth = vi.hoisted(() => ({
isCloud: false,
authHeader: null as { Authorization: string } | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockCloudAuth.isCloud
}
}))
vi.mock('@/stores/authStore', async () => {
return {
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
}))
}
})
vi.mock('@/platform/settings/settingStore', async () => {
return {
useSettingStore: () => ({
settings: {}
})
}
})
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...'
function createMockConfig(overrides = {}): RemoteWidgetConfig {
return {
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
refresh: 0,
...overrides
}
}
const createMockOptions = (inputOverrides = {}) => ({
remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: createMockLGraphNode({
addWidget: vi.fn(() => createMockWidget()),
onRemoved: undefined
}),
widget: createMockWidget()
})
function mockAxiosResponse(data: unknown, status = 200) {
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
}
function mockAxiosError(error: Error | string) {
const err = error instanceof Error ? error : new Error(error)
vi.mocked(axios.get).mockRejectedValueOnce(err)
}
function createHookWithData(data: unknown, inputOverrides = {}) {
mockAxiosResponse(data)
const hook = useRemoteWidget(createMockOptions(inputOverrides))
return hook
}
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
const hook = createHookWithData(data, inputOverrides)
const result = await getResolvedValue(hook)
return { hook, result }
}
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
// Create a promise that resolves when the fetch is complete
const responsePromise = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
await responsePromise
return hook.getCachedValue()
}
describe('useRemoteWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mocks
vi.mocked(axios.get).mockReset()
// Reset cache between tests
vi.spyOn(Map.prototype, 'get').mockClear()
vi.spyOn(Map.prototype, 'set').mockClear()
vi.spyOn(Map.prototype, 'delete').mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should create hook with default values', () => {
const hook = useRemoteWidget(createMockOptions())
expect(hook.getCachedValue()).toBeUndefined()
expect(hook.getValue()).toBe('Loading...')
})
it('should generate consistent cache keys', () => {
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
expect(hook1.cacheKey).toBe(hook2.cacheKey)
})
it('should handle query params in cache key', () => {
const hook1 = useRemoteWidget(
createMockOptions({ query_params: { a: 1 } })
)
const hook2 = useRemoteWidget(
createMockOptions({ query_params: { a: 2 } })
)
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
})
})
describe('fetchOptions', () => {
it('should fetch data successfully', async () => {
const mockData = ['optionA', 'optionB']
const { hook, result } = await setupHookWithResponse(mockData)
expect(result).toEqual(mockData)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
hook.cacheKey.split(';')[0], // Get the route part from cache key
expect.any(Object)
)
})
it('should use response_key if provided', async () => {
const mockResponse = { items: ['optionB', 'optionA', 'optionC'] }
const { result } = await setupHookWithResponse(mockResponse, {
response_key: 'items'
})
expect(result).toEqual(mockResponse.items)
})
it('should cache successful responses', async () => {
const mockData = ['optionA', 'optionB', 'optionC', 'optionD']
const { hook } = await setupHookWithResponse(mockData)
const entry = hook.getCacheEntry()
expect(entry?.data).toEqual(mockData)
expect(entry?.error).toBeNull()
})
it('should handle fetch errors', async () => {
const error = new Error('Network error')
mockAxiosError(error)
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeTruthy()
expect(entry?.lastErrorTime).toBeDefined()
})
it('should handle empty array responses', async () => {
const { result } = await setupHookWithResponse([])
expect(result).toEqual([])
})
it('should handle malformed response data', async () => {
const hook = useRemoteWidget(createMockOptions())
mockAxiosResponse(null)
const data1 = hook.getValue()
mockAxiosResponse(undefined)
const data2 = hook.getValue()
expect(data1).toBe(DEFAULT_VALUE)
expect(data2).toBe(DEFAULT_VALUE)
})
it('should handle non-200 status codes', async () => {
mockAxiosError('Request failed with status code 404')
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error?.message).toBe('Request failed with status code 404')
})
})
describe('refresh behavior', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('permanent widgets (no refresh)', () => {
it('permanent widgets should not attempt fetch after initialization', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('permanent widgets should re-fetch if refreshValue is called', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
expect(hook.getCachedValue()).toEqual(mockData)
const refreshedData = ['data that user forced to be fetched']
mockAxiosResponse(refreshedData)
hook.refreshValue()
// Wait for cache to update with refreshed data
await vi.waitFor(() => {
expect(hook.getCachedValue()).toEqual(refreshedData)
})
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('permanent widgets should still retry if request fails', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should treat empty refresh field as permanent', async () => {
const { hook } = await setupHookWithResponse(['data that is permanent'])
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
it('should refresh when data is stale', async () => {
const refresh = 256
const mockData1 = ['option1']
const mockData2 = ['option2']
const { hook } = await setupHookWithResponse(mockData1, { refresh })
mockAxiosResponse(mockData2)
vi.setSystemTime(Date.now() + refresh)
const newData = await getResolvedValue(hook)
expect(newData).toEqual(mockData2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should not refresh when data is not stale', async () => {
const { hook } = await setupHookWithResponse(['option1'], {
refresh: 512
})
vi.setSystemTime(Date.now() + 128)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('should use backoff instead of refresh after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['first success'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
mockAxiosResponse(['second success'])
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toEqual(['second success'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
})
it('should use last valid value after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['a valid value'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['a valid value'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
})
describe('error handling and backoff', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should implement exponential backoff on errors', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 500)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
vi.setSystemTime(Date.now() + 3000)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(entry1?.data).toBeDefined()
})
it('should reset error state on successful fetch', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
const firstData = await getResolvedValue(hook)
expect(firstData).toBe('Loading...')
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['option1'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['option1'])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeNull()
expect(entry?.retryCount).toBe(0)
})
it('should save successful data after backoff', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['success after backoff'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['success after backoff'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
it('should save successful data after multiple backoffs', async () => {
mockAxiosError('Network error')
mockAxiosError('Network error')
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 9000)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 120_000)
mockAxiosResponse(['success after multiple backoffs'])
const fourthData = await getResolvedValue(hook)
expect(fourthData).toEqual(['success after multiple backoffs'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
})
describe('cache management', () => {
it('should clear cache entries', async () => {
const { hook } = await setupHookWithResponse(['to be cleared'])
expect(hook.getCachedValue()).toBeDefined()
hook.refreshValue()
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should prevent duplicate in-flight requests', async () => {
const mockData = ['non-duplicate']
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
// Start two concurrent getValue calls
const promise1 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
const promise2 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
// Wait for both e
await Promise.all([promise1, promise2])
// Both should see the same cached data
expect(hook.getCachedValue()).toEqual(mockData)
// Only one axios call should have been made
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
describe('concurrent access and multiple instances', () => {
it('should handle concurrent hook instances with same route', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
// Since they have the same route, only one request will be made
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
const data1 = hook1.getValue()
const data2 = hook2.getValue()
expect(data1).toEqual(['shared data'])
expect(data2).toEqual(['shared data'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
})
it('should use shared cache across multiple hooks', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
const hook3 = useRemoteWidget(options)
const hook4 = useRemoteWidget(options)
const data1 = await getResolvedValue(hook1)
const data2 = await getResolvedValue(hook2)
const data3 = await getResolvedValue(hook3)
const data4 = await getResolvedValue(hook4)
expect(data1).toEqual(['shared data'])
expect(data2).toBe(data1)
expect(data3).toBe(data1)
expect(data4).toBe(data1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
})
it('should handle rapid cache clearing during fetch', async () => {
let resolvePromise: (value: { data: unknown; status?: number }) => void
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
(resolve) => {
resolvePromise = resolve
}
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
hook.refreshValue()
resolvePromise!({ data: ['delayed data'] })
const data = await getResolvedValue(hook)
// The value should be the default value because the refreshValue
// clears the cache and the fetch is aborted
expect(data).toEqual(DEFAULT_VALUE)
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should handle widget destroyed during fetch', async () => {
let resolvePromise: (value: { data: unknown; status?: number }) => void
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
(resolve) => {
resolvePromise = resolve
}
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
let hook: ReturnType<typeof useRemoteWidget> | null =
useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()
hook = null
resolvePromise!({ data: ['delayed data'] })
await fetchPromise
expect(hook).toBeNull()
hook = useRemoteWidget(createMockOptions())
const data2 = await getResolvedValue(hook)
expect(data2).toEqual(DEFAULT_VALUE)
})
})
describe('cloud distribution authentication', () => {
describe('when distribution is cloud', () => {
describe('when authenticated', () => {
it('passes Firebase authentication token in request headers', async () => {
const mockData = ['authenticated data']
mockCloudAuth.authHeader = null
mockCloudAuth.isCloud = true
mockCloudAuth.authHeader = { Authorization: 'Bearer test-token' }
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { Authorization: 'Bearer test-token' }
})
)
})
})
})
describe('when distribution is not cloud', () => {
it('bypasses authentication for non-cloud environments', async () => {
const mockData = ['non-cloud data']
mockCloudAuth.isCloud = false
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const axiosCall = vi.mocked(axios.get).mock.calls[0][1]
expect(axiosCall).not.toHaveProperty('headers')
})
})
})
describe('auto-refresh on task completion', () => {
it('should add auto-refresh toggle widget', () => {
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({
refresh: vi.fn()
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Should add auto-refresh toggle widget
expect(mockNode.addWidget).toHaveBeenCalledWith(
'toggle',
'Auto-refresh after generation',
false,
expect.any(Function),
{
serialize: false
}
)
})
it('should register event listener when enabled', async () => {
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({
refresh: vi.fn()
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Event listener should be registered immediately
expect(addEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
expect.any(Function)
)
})
it('should refresh widget when workflow completes successfully', async () => {
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Get the toggle callback and enable auto-refresh
const addWidgetMock = mockNode.addWidget as ReturnType<typeof vi.fn>
const toggleCallback = addWidgetMock.mock.calls.find(
(call: unknown[]) => call[0] === 'toggle'
)?.[3]
toggleCallback?.(true)
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).toHaveBeenCalled()
})
it('should not refresh when toggle is disabled', async () => {
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Toggle is disabled by default
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).not.toHaveBeenCalled()
})
it('should cleanup event listener on node removal', async () => {
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: [],
onRemoved: undefined
})
const mockWidget = createMockWidget({
refresh: vi.fn()
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Simulate node removal
mockNode.onRemoved?.()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
executionSuccessHandler
)
})
})
})

View File

@@ -1,26 +1,20 @@
import axios from 'axios'
import { isRetriableError } from '@/base/remote/retry'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import { getAppQueryClient } from '@/platform/remote/queryClient'
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
const MAX_RETRIES = 5
const TIMEOUT = 4096
interface CacheEntry<T> {
data: T
timestamp?: number
error?: Error | null
fetchPromise?: Promise<T>
controller?: AbortController
lastErrorTime?: number
retryCount?: number
failed?: boolean
}
async function getAuthHeaders() {
if (isCloud) {
const authStore = useAuthStore()
@@ -32,57 +26,32 @@ async function getAuthHeaders() {
return {}
}
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data !== undefined &&
entry?.timestamp !== undefined &&
entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
entry?.fetchPromise !== undefined
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
entry?.failed === true
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
entry?.error &&
entry?.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
const fetchData = async (
config: RemoteWidgetConfig,
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const createDescriptor = (
config: RemoteWidgetConfig
): RemoteRequestDescriptor => ({
client: 'comfyApi',
route: config.route,
params: config.query_params,
responseKey: config.response_key,
ttl: config.refresh,
timeout: config.timeout ?? TIMEOUT,
maxRetries: config.max_retries ?? MAX_RETRIES
})
async function fetchRemoteWidgetData(
descriptor: RemoteRequestDescriptor,
signal: AbortSignal
): Promise<unknown> {
const authHeaders = await getAuthHeaders()
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout,
const res = await axios.get(descriptor.route, {
params: descriptor.params,
signal,
timeout: descriptor.timeout,
...authHeaders
})
return response_key ? res.data[response_key] : res.data
return descriptor.responseKey
? (res.data as Record<string, unknown>)[descriptor.responseKey]
: res.data
}
export function useRemoteWidget<
@@ -94,42 +63,39 @@ export function useRemoteWidget<
widget: IWidget
}) {
const { remoteConfig, defaultValue, node, widget } = options
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(remoteConfig)
const descriptor = createDescriptor(remoteConfig)
const queryClient = getAppQueryClient()
const getQueryKey = () =>
remoteOptionKeys.byRoute(descriptor, {
userId: useAuthStore().userId ?? null,
workspaceId: null,
apiKeyBucket: useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
})
let isLoaded = false
let refreshQueued = false
let cachedValue: T | undefined
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
entry.fetchPromise = undefined
if (entry.retryCount >= max_retries) {
setFailed(entry)
const fetchValue = async (): Promise<T> => {
try {
const data = await queryClient.fetchQuery({
queryKey: getQueryKey(),
queryFn: ({ signal }) => fetchRemoteWidgetData(descriptor, signal),
staleTime: remoteConfig.refresh,
retry: (failureCount, error) =>
failureCount < (remoteConfig.max_retries ?? MAX_RETRIES) &&
isRetriableError(error)
})
cachedValue = (data ?? defaultValue) as T
return cachedValue
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.warn('Remote widget fetch failed:', message)
cachedValue = (cachedValue ?? defaultValue) as T
return cachedValue
}
}
const setFailed = (entry: CacheEntry<T>) => {
dataCache.set(cacheKey, {
data: entry.data ?? defaultValue,
failed: true
})
}
const isFirstLoad = () => {
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T | T[]) => {
isLoaded = true
const nextValue =
@@ -139,85 +105,37 @@ export function useRemoteWidget<
node.graph?.setDirtyCanvas(true)
}
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data as T
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry))
return entry!.data as T
const currentEntry: CacheEntry<T> = (entry as
| CacheEntry<T>
| undefined) || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
const onRefresh = () => {
if (remoteConfig.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
if (!remoteConfig.control_after_refresh) return
const data = cachedValue
if (!Array.isArray(data)) return
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
/**
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
*/
const clearCachedValue = () => {
const entry = dataCache.get(cacheKey)
if (!entry) return
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
function getCachedValue(): T {
if (cachedValue !== undefined) return cachedValue
const fromQuery = queryClient.getQueryData<T>(getQueryKey())
if (fromQuery !== undefined) {
cachedValue = fromQuery
return fromQuery
}
return defaultValue
}
/**
* Get the cached value of the widget without starting a new fetch.
* @returns the most recently computed value of the widget.
*/
function getCachedValue() {
return dataCache.get(cacheKey)?.data as T
}
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
void fetchValue()
.then((data) => {
if (isFirstLoad()) onFirstLoad(data)
if (!isLoaded) onFirstLoad(data)
if (refreshQueued && data !== defaultValue) {
onRefresh()
refreshQueued = false
@@ -230,36 +148,26 @@ export function useRemoteWidget<
return getCachedValue() ?? defaultValue
}
/**
* Force the widget to refresh its value
*/
widget.refresh = function () {
refreshQueued = true
clearCachedValue()
getValue()
void queryClient.invalidateQueries({ queryKey: getQueryKey() }).then(() => {
getValue()
})
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
@@ -272,10 +180,8 @@ export function useRemoteWidget<
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
@@ -283,7 +189,6 @@ export function useRemoteWidget<
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
@@ -291,8 +196,6 @@ export function useRemoteWidget<
getValue,
refreshValue: widget.refresh,
addRefreshButton,
getCacheEntry: () => dataCache.get(cacheKey),
cacheKey
getQueryKey
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { zComboInputOptionsValidated } from '@/schemas/nodeDefSchema'
describe('zComboInputOptionsValidated XOR enforcement', () => {
const remote = {
route: '/legacy'
}
const remote_combo = {
route: '/rich',
item_schema: { value_field: 'id', label_field: 'name' }
}
it('accepts options without remote or remote_combo', () => {
const result = zComboInputOptionsValidated.safeParse({})
expect(result.success).toBe(true)
})
it('accepts options with only remote', () => {
const result = zComboInputOptionsValidated.safeParse({ remote })
expect(result.success).toBe(true)
})
it('accepts options with only remote_combo', () => {
const result = zComboInputOptionsValidated.safeParse({ remote_combo })
expect(result.success).toBe(true)
})
it('rejects options with both remote and remote_combo', () => {
const result = zComboInputOptionsValidated.safeParse({
remote,
remote_combo
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0]?.message).toContain(
'Combo input cannot specify both'
)
}
})
})

View File

@@ -5,6 +5,11 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
/**
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
*/
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -15,6 +20,32 @@ const zRemoteWidgetConfig = z.object({
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional()
})
/**
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, and filtering.
* Requires `item_schema`. Vue-nodes only. Routes are always relative paths and resolve against
* the comfy-api base URL with auth headers injected. The endpoint returns the full items array
* in a single response.
*/
const zRemoteComboConfig = z.object({
route: z.string().startsWith('/'),
item_schema: zRemoteItemSchema,
refresh_button: z.boolean().optional(),
auto_select: z.enum(['first', 'last']).optional(),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
@@ -96,10 +127,20 @@ export const zComboInputOptions = zBaseInputOptions.extend({
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
remote_combo: zRemoteComboConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
export const zComboInputOptionsValidated = zComboInputOptions.refine(
(opts) => !(opts.remote && opts.remote_combo),
{
message:
'Combo input cannot specify both `remote` and `remote_combo`; pick one.',
path: ['remote_combo']
}
)
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
const zFloatInputSpec = z.tuple([
z.literal('FLOAT'),
@@ -352,7 +393,9 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>

View File

@@ -71,4 +71,66 @@ describe('validateNodeDef', () => {
})
}
)
describe('remote_combo route validation', () => {
const buildNodeDef = (remoteCombo: object): unknown => ({
...EXAMPLE_NODE_DEF,
input: {
required: {
voice: ['COMBO', { remote_combo: remoteCombo }]
}
}
})
const baseRemoteCombo = {
item_schema: { value_field: 'id', label_field: 'name' }
}
it('accepts a relative route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: '/voices'
})
)
).not.toBeNull()
})
it('rejects an absolute http URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'http://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects an absolute https URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects a route with no leading slash', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'voices'
}),
() => {}
)
).toBeNull()
})
})
})

View File

@@ -0,0 +1,48 @@
import { vi } from 'vitest'
export const EXPECTED_WORKFLOW = {
nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }]
}
export const EXPECTED_PROMPT = {
'1': { class_type: 'KSampler', inputs: {} }
}
type ReadMethod = 'readAsText' | 'readAsArrayBuffer'
export function mockFileReaderError(method: ReadMethod): void {
vi.spyOn(FileReader.prototype, method).mockImplementation(
function (this: FileReader) {
queueMicrotask(() =>
this.onerror?.(new ProgressEvent('error') as ProgressEvent<FileReader>)
)
}
)
}
export function mockFileReaderAbort(method: ReadMethod): void {
vi.spyOn(FileReader.prototype, method).mockImplementation(
function (this: FileReader) {
queueMicrotask(() =>
this.onabort?.(new ProgressEvent('abort') as ProgressEvent<FileReader>)
)
}
)
}
export function mockFileReaderResult(
method: ReadMethod,
result: string | ArrayBuffer | null
): void {
vi.spyOn(FileReader.prototype, method).mockImplementation(
function (this: FileReader) {
Object.defineProperty(this, 'result', {
value: result,
configurable: true
})
queueMicrotask(() =>
this.onload?.(new ProgressEvent('load') as ProgressEvent<FileReader>)
)
}
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -1,7 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
import { getFromAvifFile } from './avif'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.avif')
afterEach(() => vi.restoreAllMocks())
describe('AVIF metadata', () => {
it('extracts workflow and prompt from EXIF data in ISOBMFF boxes', async () => {
const bytes = fs.readFileSync(fixturePath)
const file = new File([bytes], 'test.avif', { type: 'image/avif' })
const result = await getFromAvifFile(file)
expect(JSON.parse(result.workflow)).toEqual(EXPECTED_WORKFLOW)
expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT)
})
it('returns empty for non-AVIF data', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const file = new File([new Uint8Array(16)], 'fake.avif')
const result = await getFromAvifFile(file)
expect(result).toEqual({})
expect(console.error).toHaveBeenCalledWith('Not a valid AVIF file')
})
it('returns empty when AVIF has valid ftyp but corrupt internal boxes', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const buf = new Uint8Array(40)
const dv = new DataView(buf.buffer)
dv.setUint32(0, 16)
buf.set(new TextEncoder().encode('ftypavif'), 4)
dv.setUint32(16, 24)
buf.set(new TextEncoder().encode('meta'), 20)
const file = new File([buf], 'corrupt.avif', { type: 'image/avif' })
const result = await getFromAvifFile(file)
expect(result).toEqual({})
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('Error parsing AVIF metadata'),
expect.anything()
)
})
describe('FileReader failure modes', () => {
const file = new File([new Uint8Array(16)], 'test.avif')
it('resolves empty when the FileReader fires error', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockFileReaderError('readAsArrayBuffer')
expect(await getFromAvifFile(file)).toEqual({})
})
it('resolves empty when the FileReader fires abort', async () => {
mockFileReaderAbort('readAsArrayBuffer')
expect(await getFromAvifFile(file)).toEqual({})
})
})
})
const setU32BE = (dv: DataView, off: number, val: number) =>
dv.setUint32(off, val, false)
const setU16BE = (dv: DataView, off: number, val: number) =>

View File

@@ -407,6 +407,7 @@ export function getFromAvifFile(file: File): Promise<Record<string, string>> {
console.error('FileReader: Error reading AVIF file:', err)
resolve({})
}
reader.onabort = () => resolve({})
reader.readAsArrayBuffer(file)
})
}

View File

@@ -0,0 +1,49 @@
import fs from 'fs'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
import { getFromWebmFile } from './ebml'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.webm')
describe('WebM/EBML metadata', () => {
it('extracts workflow and prompt from EBML SimpleTag elements', async () => {
const bytes = fs.readFileSync(fixturePath)
const file = new File([bytes], 'test.webm', { type: 'video/webm' })
const result = await getFromWebmFile(file)
expect(result.workflow).toEqual(EXPECTED_WORKFLOW)
expect(result.prompt).toEqual(EXPECTED_PROMPT)
})
it('returns empty for non-WebM data', async () => {
const file = new File([new Uint8Array(16)], 'fake.webm')
const result = await getFromWebmFile(file)
expect(result).toEqual({})
})
describe('FileReader failure modes', () => {
afterEach(() => vi.restoreAllMocks())
const file = new File([new Uint8Array(16)], 'test.webm')
it('resolves empty when the FileReader fires error', async () => {
mockFileReaderError('readAsArrayBuffer')
expect(await getFromWebmFile(file)).toEqual({})
})
it('resolves empty when the FileReader fires abort', async () => {
mockFileReaderAbort('readAsArrayBuffer')
expect(await getFromWebmFile(file)).toEqual({})
})
})
})

View File

@@ -353,6 +353,7 @@ export function getFromWebmFile(file: File): Promise<ComfyMetadata> {
const reader = new FileReader()
reader.onload = (event) => handleFileLoad(event, resolve)
reader.onerror = () => resolve({})
reader.onabort = () => resolve({})
reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES))
})
}

View File

@@ -0,0 +1,56 @@
import fs from 'fs'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
import { getFromFlacBuffer, getFromFlacFile } from './flac'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.flac')
afterEach(() => vi.restoreAllMocks())
describe('FLAC metadata', () => {
it('extracts workflow and prompt from Vorbis comments', () => {
const bytes = fs.readFileSync(fixturePath)
const buffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
)
const result = getFromFlacBuffer(buffer)
expect(result.workflow).toBe(JSON.stringify(EXPECTED_WORKFLOW))
expect(result.prompt).toBe(JSON.stringify(EXPECTED_PROMPT))
})
it('returns undefined for non-FLAC data', () => {
const buf = new ArrayBuffer(16)
const result = getFromFlacBuffer(buf)
expect(result).toBeUndefined()
})
describe('FileReader failure modes', () => {
const file = new File([new Uint8Array(16)], 'test.flac')
it('resolves empty when the FileReader fires error', async () => {
mockFileReaderError('readAsArrayBuffer')
const result = await getFromFlacFile(file)
expect(result).toEqual({})
})
it('resolves empty when the FileReader fires abort', async () => {
mockFileReaderAbort('readAsArrayBuffer')
const result = await getFromFlacFile(file)
expect(result).toEqual({})
})
})
})

View File

@@ -42,6 +42,8 @@ export function getFromFlacFile(file: File): Promise<Record<string, string>> {
const arrayBuffer = event.target.result as ArrayBuffer
r(getFromFlacBuffer(arrayBuffer))
}
reader.onerror = () => r({})
reader.onabort = () => r({})
reader.readAsArrayBuffer(file)
})
}

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
import {
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
import { getGltfBinaryMetadata } from './gltf'
describe('GLTF binary metadata parser', () => {
@@ -160,4 +164,20 @@ describe('GLTF binary metadata parser', () => {
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)
expect(metadata).toEqual({})
})
describe('FileReader failure modes', () => {
afterEach(() => vi.restoreAllMocks())
const file = new File([new Uint8Array(16)], 'test.glb')
it('resolves empty when the FileReader fires error', async () => {
mockFileReaderError('readAsArrayBuffer')
expect(await getGltfBinaryMetadata(file)).toEqual({})
})
it('resolves empty when the FileReader fires abort', async () => {
mockFileReaderAbort('readAsArrayBuffer')
expect(await getGltfBinaryMetadata(file)).toEqual({})
})
})
})

View File

@@ -165,6 +165,7 @@ export function getGltfBinaryMetadata(file: File): Promise<ComfyMetadata> {
}
}
reader.onerror = () => resolve({})
reader.onabort = () => resolve({})
reader.readAsArrayBuffer(file.slice(0, bytesToRead))
})
}

View File

@@ -0,0 +1,52 @@
import fs from 'fs'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
import { getFromIsobmffFile } from './isobmff'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp4')
describe('ISOBMFF (MP4) metadata', () => {
it('extracts workflow and prompt from QuickTime keys/ilst boxes', async () => {
const bytes = fs.readFileSync(fixturePath)
const file = new File([bytes], 'test.mp4', { type: 'video/mp4' })
const result = await getFromIsobmffFile(file)
expect(result.workflow).toEqual(EXPECTED_WORKFLOW)
expect(result.prompt).toEqual(EXPECTED_PROMPT)
})
it('returns empty for non-ISOBMFF data', async () => {
const file = new File([new Uint8Array(16)], 'fake.mp4', {
type: 'video/mp4'
})
const result = await getFromIsobmffFile(file)
expect(result).toEqual({})
})
describe('FileReader failure modes', () => {
afterEach(() => vi.restoreAllMocks())
const file = new File([new Uint8Array(16)], 'test.mp4')
it('resolves empty when the FileReader fires error', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockFileReaderError('readAsArrayBuffer')
expect(await getFromIsobmffFile(file)).toEqual({})
})
it('resolves empty when the FileReader fires abort', async () => {
mockFileReaderAbort('readAsArrayBuffer')
expect(await getFromIsobmffFile(file)).toEqual({})
})
})
})

Some files were not shown because too many files have changed in this diff Show More