Compare commits

...

23 Commits

Author SHA1 Message Date
Simon Pinfold
c9649228bd fix: always toggle unified Model Library tab from sidebar button
The model-library toggle command redirected to Comfy.BrowseModelAssets
whenever the UseAssetAPI setting was on. On cloud that command toggles
the same tab anyway; on local builds it opened the legacy asset-browser
overlay, making the new tab unreachable for anyone who had enabled the
asset API experiment. Drop the redirect so the button always toggles
the tab.
2026-06-10 17:53:23 +12:00
Simon Pinfold
bf481f2c71 fix: adapt FormDropdown to useTransformCompatOverlayProps removal
Main removed the composable in #12513 (dropdowns now append to body
directly). The picker already used a constant appendTo:'body' override,
so pass it inline.
2026-06-10 10:42:24 +12:00
Simon Pinfold
45fef1d89c merge: PR #12635 feat: redesign in-node model/media picker 2026-06-10 10:38:23 +12:00
Simon Pinfold
cf996d8e39 merge: PR #12634 feat: add Model Library sidebar tab (cloud + local) 2026-06-10 10:37:48 +12:00
Simon Pinfold
ebf70db149 merge: PR #12633 feat: add Model Library data foundation 2026-06-10 10:37:05 +12:00
imick-io
ed4f7db7f4 feat(website): add affiliate program page at /affiliates (#12670)
## Summary

- Adds the Comfy affiliate program landing page at `/affiliates`,
composed from 8 reusable presentational blocks (`HeroSplit01`,
`ChecklistSplit01`, `StepsGrid01`, `BenefitsGrid01`, `DetailsTable01`,
`BrandAssetsGrid01`, `FAQSplit01`, `CtaCenter01`) and thin section
wrappers under `templates/affiliate/*`. The page funnels through: Hero →
Audience → How It Works → Why ComfyUI → Program Details → Brand Assets →
FAQ → closing "Ready to start earning?" CTA.
- Removes the older parallel implementation under
`components/affiliates/*` (7 Vue sections, 3 data files, the i18n
contract test) and the `affiliate-landing.*` translation keys — the page
now has a single canonical i18n surface under `affiliate.*`.
- Rewrites `e2e/affiliates.spec.ts` to target the new sections via
semantic queries (no testids were added to production), covering hero
indexability, the closing CTA's apply + terms links, FAQPage JSON-LD
entry count, and FAQ toggle behavior.
- Apply CTA links to the application form
(`forms.gle/RS8L2ttcuGap4Q1v6`, `target=_blank`); terms link goes to
`/affiliates/terms` (English-only by design, per the legal-reviewed
terms document).

## Test plan

- [ ] `pnpm --filter @comfyorg/website typecheck` passes (0 errors)
- [ ] `pnpm --filter @comfyorg/website test:unit` passes (96 tests
across 13 files)
- [ ] `pnpm --filter @comfyorg/website knip --cache` reports no new
orphans introduced by this branch
- [ ] Visit `/affiliates` — all 8 sections render in order on desktop
and mobile
- [ ] APPLY NOW (in closing CTA section) opens
`forms.gle/RS8L2ttcuGap4Q1v6` in a new tab
- [ ] "Read the affiliate program terms" link navigates to
`/affiliates/terms` in the same tab
- [ ] FAQ items expand/collapse on click; `<script
type="application/ld+json">` FAQPage entry contains all 8 Q/A pairs from
`src/data/affiliateFaq.ts`
- [ ] Run e2e: `pnpm --filter @comfyorg/website playwright test
e2e/affiliates.spec.ts`

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 20:34:43 +00:00
imick-io
39157f2375 Feat/models page (#12429)
## Summary

Add a new `/models` landing page (EN + zh-CN) for the marketing site,
plus supporting tweaks: a generator-side mechanism for old→new
model-slug 301 redirects, spacing/CTA polish on shared sections, and a
per-item layout option on GalleryCard.

## Changes

- **What**:
- New `/models` and `/zh-CN/models` pages composed of a hero (autoplay
video, modelName + i18n CTA), a creations gallery, and the shared
`AIModelsSection`.
- New `ModelsHeroSection.vue` and `ModelCreationsSection.vue`
components; localised strings under `models.list.*` / `models.hero.*` in
`translations.ts`.
- Reused `AIModelsSection` on the models page (replaces the duplicated
showcase markup that previously lived under `models/`) so the same
component now powers both the cloud product page and the models page.
- `generate-models.ts`: renamed the `grok` provider to **Grok Imagine**
(`grok-imagine` slug) and added a `LEGACY_SLUG_REDIRECTS` constant that
emits stub entries so the existing `canonicalSlug` mechanism in
`[slug].astro` issues a 301 from old slugs (`grok-image` →
`grok-imagine`).
- `model-metadata.ts`: renamed the metadata key from `grok-image` to
`grok-imagine` (hub slug unchanged).
- `GalleryCard.vue`: added per-item `objectFit` / `objectPosition`
overrides so individual gallery entries can opt out of the default
`cover` crop.
- `ModelsHeroSection.vue`: dropped the empty-string default on
`videoAriaLabel` and omit `aria-label` (with `aria-hidden="true"`) when
no label is provided — addresses the CodeRabbit accessibility note.
- Minor vertical-spacing tightening on `ModelCreationsSection` and the
shared `AIModelsSection`.
- **Breaking**: None. Old `/p/supported-models/grok-image` URLs 301 to
the new slug.
- **Dependencies**: None.

## Review Focus

- The `LEGACY_SLUG_REDIRECTS` constant in
`apps/website/scripts/generate-models.ts` is the new source of truth for
renamed slugs — future slug renames should follow the same pattern so
the redirect survives regeneration of `generated-models.json`.
- The shared `AIModelsSection` is now used in two places; confirm the
spacing tweak (`py-24 → py-16`, `mt-24 → mt-16 lg:mt-24`) still looks
correct on the cloud product page.
- `generated-models.json` is fully regenerated by `pnpm generate:models`
— diff size is large but mechanical.

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-09 20:14:03 +00:00
Matt Miller
47118ef64f fix(image): handle useImage load errors instead of reporting them as unhandled (#12729)
## ELI-5

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

## What

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

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

## Fix

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

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

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

## Test plan

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

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

---------

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

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

## What

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

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

## Why it matters

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

## Fix

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

## Test plan

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

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

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

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

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

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

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

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

## Root Cause

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

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

## Changes

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

## User Impact

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

## Validation

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

## Follow-up Work

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

## Notes

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

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

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

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

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

## Changes

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

## Planned stack

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

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

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

## Review Focus

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

## Screenshots (if applicable)

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

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

## Validation

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

## Review notes

Claude review was run after the main implementation and again after
cleanup. No blocker or major issues remained. The only final minor
finding was stale inline i18n fixture data in `TabErrors.test.ts`, which
was removed in the final commit.
2026-06-09 06:52:35 +00:00
Comfy Org PR Bot
e340b5b127 1.46.11 (#12722)
Patch version increment to 1.46.11

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-09 05:56:12 +00:00
Deep Mehta
68238a5742 fix(website): show Windows + Mac fallback on /download when UA detection fails (#12727)
## What
The /download hero now surfaces both Windows AND macOS download buttons
when the post-mount UA sniff comes back empty (Linux desktop, mobile,
privacy-stripped UAs). Today those users see only the GitHub-install
button — no actual download link.

## Why
Field complaint: \"I can't find a download link at all on this page.\"
Reproduced on Chrome with a Linux UA — \`useDownloadUrl\` returns
\`platform: null\`, and \`<DownloadLocalButton v-show=\"platform\">\`
collapses to \`display: none\`. The user only sees \"INSTALL FROM
GITHUB\" next to it, with no obvious way to grab the Windows or Mac dmg.

Showing both is the better fallback — they're the two artifacts we
actually ship today (\`downloadUrls.windows\`, \`downloadUrls.macArm\`),
the UA path already encodes them, and the buttons exist as fragment
siblings so they slot into the parent's flex container next to the
existing GitHub button without extra layout wrapping.

## How
- \`useDownloadUrl\` now exposes a \`detected\` ref that flips to
\`true\` post-mount regardless of whether UA matched. Lets the component
distinguish three states:
- **\`!detected\`** → pre-hydration, render nothing (avoids SSR flicker)
  - **\`detected && platform\`** → single matched CTA, current behaviour
  - **\`detected && !platform\`** → NEW: render both Windows AND Mac
- \`DownloadLocalButton\` adds the fallback branch as a \`<template
v-else-if>\` fragment so the two buttons slot directly into the parent's
existing \`flex-col lg:flex-row\` layout next to the GitHub button. Each
carries its OS icon and an \`aria-label\` combining the existing
\"DOWNLOAD DESKTOP\" copy with the OS name so screen readers get equal
context to the detected-OS path.
- \`downloadUrls\` is now re-exported from the composable so the
fallback path uses the same source of truth instead of duplicating the
URLs.

## Tests
- [x] New \`@smoke\` e2e test (\`HeroSection falls back to both Windows
+ Mac when UA is unrecognized\`) — uses Linux UA, asserts both fallback
buttons appear with the right hrefs / target=\"_blank\", and confirms
the auto-detected single CTA is NOT also present
- [x] Existing Windows-UA test (\`HeroSection has download and GitHub
buttons\`) still passes — single CTA branch unchanged
- [ ] Manual: load /download on Linux Chrome → both buttons visible
- [ ] Manual: load /download on Windows → still single \"DOWNLOAD
DESKTOP\" button (no regression)
- [ ] Manual: load /download on macOS → still single \"DOWNLOAD
DESKTOP\" button with Apple icon
- [ ] Manual: load /download on mobile (which detection treats as null)
→ both fallback buttons visible
2026-06-09 05:50:48 +00:00
Comfy Org PR Bot
183a75a218 [chore] Update Ingest API types from cloud@ca12913 (#12726)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: ca12913
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 04:39:50 +00:00
Dante
507e667795 feat: register unified_cloud_auth feature flag (FE-950) - step 1 (#12702)
## Summary
https://us.posthog.com/project/204330/feature_flags/706651 

Register the `unified_cloud_auth` server feature flag (default **off**)
so the upcoming single Cloud-JWT auth provider (FE-950/FE-951) can be
gated behind it. Pure plumbing — no behavior change.

## Changes

- **What**: Adds `ServerFeatureFlag.UNIFIED_CLOUD_AUTH`, the
`unifiedCloudAuthEnabled` getter in `useFeatureFlags` (via
`resolveFlag`, precedence: dev override > `remoteConfig` > server
feature), and the `unified_cloud_auth?: boolean` field on the
`RemoteConfig` type. Unit tests cover the server-feature wiring and the
dev-override toggle.
- This is PR 1 of 3 for FE-950. Nothing reads the flag yet; it resolves
`false` by default, so this is inert in every environment until a later
PR wires consumers.

## Review Focus

- Mirrors the existing `resolveFlag`-based flags (e.g.
`userSecretsEnabled`, `workflowSharingEnabled`) — deliberately the plain
shape, **not** the cloud-gated `teamWorkspacesEnabled` shape, since the
flag is only read from paths already `isCloud`-gated.
- Local toggle for development:
`localStorage.setItem('ff:unified_cloud_auth', 'true')` (DEV-only dev
override).
- Production delivery of the flag value (and staged % rollout) is
backend work — Comfy-Org/cloud#4139 (BE-1258), already adds
`unified_cloud_auth` to `/api/features`. This FE PR is independent of
it; with no server value the flag stays `false`.

Linear: FE-950

## Screenshots (if applicable)

N/A — no user-facing change (flag registration only).
2026-06-09 02:15:33 +00:00
Comfy Org PR Bot
d617ab1c95 [chore] Update Ingest API types from cloud@1dc7e7e (#12227)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 1dc7e7e
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-09 01:29:43 +00:00
Dante
b93338cac7 fix(keybindings): progressively truncate keybinding column at narrow widths (#12181)
## Summary
- Progressive container-query truncation for the **Keybinding** column
in the Settings → Keybinding panel so the **actions column stops getting
clipped** at narrow widths.
- Tiers (all driven by `@container/keybindings` on the cell content — no
JS resize listeners):
  - `>= 16rem`: `Ctrl S , Ctrl Shift S + 1 more` (2 combos + `N-2 more`)
  - `>= 12rem`: `Ctrl S + 2 more` (1 combo + `N-1 more`)
  - `>= 8rem`: `Ctrl S + 2` (1 combo + compact `+N`)
  - smallest: first combo only
- Actions column pinned with `whitespace-nowrap` so it always stays
fully visible — the keybindings column compresses first, not the icons.
- New `KeybindingList.vue` extracted from `KeybindingPanel.vue`; the
prior inline rendering was inlined twice (column body + we'd need it
elsewhere if expansion logic changes).
- Adds `g.nMoreKeybindingsCompact` and `g.keybindingListAriaLabel` i18n
keys (en only; other locales fall back to en).

## Before / after

### before


https://github.com/user-attachments/assets/227cdd6a-fd5d-41ba-8af2-2a49e2d9cf15

### after
<img width="936" height="812" alt="Screenshot 2026-05-13 at 11 05 12 AM"
src="https://github.com/user-attachments/assets/f750efd8-7d14-4a46-a92d-fa67e57c7909"
/>








<!-- Drag the PNGs from ~/Desktop/fe-523-screenshots/ into this section.
Recommended order: wide → 16rem → 12rem → 8rem, before then after at
each tier.
Captured by temp/scripts/capture-fe523-screenshots.ts at 4 dialog
widths. -->

_screenshots pending — drag/drop from `~/Desktop/fe-523-screenshots/`_

The narrow tier shows the original FE-523 bug clearly: on `main` the
Source and Actions columns are clipped without a scrollbar; with this PR
the keybinding column compresses first, keeping at least the edit icon
visible. Some action icons still get clipped at the absolute narrowest
tier — full sticky-actions behavior is a follow-up.

## Why draft
Manual visual verification of the four container-query tiers on a real
backend still needs to happen — the snapshot script runs against a
stubbed-out backend, so the dataset is sparse (the multi-combo rows
aren't injected). Moving out of draft after that pass.

## References
- Fixes
[FE-523](https://linear.app/comfyorg/issue/FE-523/update-keybinding-panel-responsiveness)
- Figma proposal:
https://www.figma.com/design/ZNLRans6JLM3hvCz5LlCQA/Keyboard-Shortcut?node-id=625-17286
- Slack thread:
https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1776818615078629?thread_ts=1776808927.654249&cid=C075ANWQ8KS

## Test plan
- [x] `pnpm test:unit` — new `KeybindingList.test.ts` covers all 4 tiers
(0/1/2/3+ keybindings, count math `N-2` vs `N-1`, container class
present, aria-label assembled).
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] Snapshot diff at 4 widths (see Before / after section above once
images are attached).
- [ ] Manual on a real backend: command with 1 / 2 / 3 / 5 keybindings
each renders the correct `+N more` / `+N` value at each tier.
- [ ] Manual: confirm whether residual actions-column clipping at the
absolute narrowest tier warrants a follow-up.
2026-06-08 23:26:13 +00:00
Deep Mehta
fa2d187c83 feat(website): add Desktop privacy policy at /privacy/desktop (#12714)
## Summary

Publishes the Comfy Desktop privacy policy (currently bundled in-app
only) as a public page at \`/privacy/desktop\`, so users can read what
they consented to without re-opening the app and so the document is
verifiable / link-shareable. Also adds a single-sentence link from the
existing \`/privacy-policy\` page so people who land on the org-wide
policy can find the surface-specific one.

## Changes

- **What**: New marketing-site page at \`/privacy/desktop\` (en) and
\`/zh-CN/privacy/desktop\` (zh), backed by ~50 new \`desktop_privacy.*\`
i18n keys mirroring the in-app document. One-sentence addition to
\`privacy.intro.block.3\` linking to the new page.
- **Breaking**: None.
- **Dependencies**: None.

## Why now

Recent Discord audit by a user (cited Civitai as precedent) identified
six legitimate gaps between our website privacy policy and what GDPR /
UK GDPR Art 13 requires (named processors, lawful basis mapping,
retention specifics, international transfer mechanism, etc.). The
**in-app Desktop policy already addresses all of them**, but it isn't
publicly visible. This PR makes it visible at a URL.

A separate, counsel-reviewed PR will be needed to bring the org-wide
\`/privacy-policy\` page up to the same standard — that's deliberately
not in scope here.

## Content the new page covers

Mirrors the Desktop in-app document (effective 2026-05-19):

- Named processors: PostHog, Datadog, ToDesktop, Comfy Org analytics
warehouse
- Article 6 mapping: 6(1)(a) consent for analytics + crash reports;
6(1)(f) legitimate interests for update server
- Retention: 24 months analytics / 15 days crash / 36 months aggregate /
90 days update logs / removed on uninstall
- International transfers: Standard Contractual Clauses where required
- Data subject rights + \`privacy@comfy.org\` as contact
- Right to lodge complaint with ICO / EU supervisory authority / CPPA

## Review Focus

1. **i18n zh-CN placeholders.** Following the existing convention in
this file (see e.g. \`affiliate-landing.footerCta\`), zh-CN values use
the English text as placeholders. A follow-up PR will land Chinese
translations. Flag if there's a different preferred convention.
2. **Source-of-truth coupling.** This page is a publish of the bundled
in-app policy at \`src/renderer/src/lib/legalDocs.ts\`
(\`PRIVACY_POLICY\` constant) in the \`Comfy-Desktop\` repo. There's no
automated sync — the two need to stay aligned manually when the in-app
policy is revised. Worth a quarterly review reminder.
3. **\`privacy@comfy.org\` mailbox.** The policy directs all data
subject rights requests there. Confirm this inbox exists and is
monitored before merge.
4. **No \`noindex\` on the new page.** Unlike the existing
\`/privacy-policy\` page, this one is intentionally indexable — the
point is that it should be discoverable.

## Test plan

- [x] \`pnpm typecheck\` clean (0 errors, 0 warnings)
- [x] \`pnpm build\` succeeds; both \`/privacy/desktop/index.html\` and
\`/zh-CN/privacy/desktop/index.html\` are emitted
- [x] All 385 site pages still build
- [ ] After preview deploy: verify the link from \`/privacy-policy\` to
\`/privacy/desktop\` renders correctly, and the page table-of-contents
nav works (\`ContentSection\` infers sections from i18n keys)

## Companion items (not in this PR)

- Update \`comfy.org/privacy-policy\` (the org-wide policy) with named
processors, Art 6 mapping, retention specifics, and SCC language —
requires privacy counsel review
- Confirm Comfy Org Inc ICO registration status
- One-page determination from privacy counsel on whether Art 37 requires
a formal DPO (pseudonymous product telemetry is likely below threshold,
but the question deserves a documented answer)
2026-06-08 23:22:43 +00:00
shrimbly
6c0c603576 feat: redesign in-node model/media picker
Reworks the widget select dropdown into a model/media picker:

- FormDropdown with a new FormDropdownActionPopover and menu/actions/
  filter/item subcomponents, shared sort helpers, and types.
- WidgetSelect / WidgetSelectDropdown wire asset-mode browsing:
  useAssetWidgetData sources cloud or on-disk local models, with
  base-model grouping, recently-used pins, and the media import button.

Reconciles upstream's FE-227 hash fallback in useWidgetSelectItems and
removes the final knip stacked-PR ignore now that useRecentlyUsedModels
is consumed.
2026-06-04 10:05:09 +12:00
shrimbly
a13d6cf99e refactor: decompose Model Library sidebar tab
Split the 867-line CloudModelLibrarySidebarTab into focused units:

- modelLibraryGrouping: pure asset→group heuristics (firstNonModelsTag,
  groupIdForAsset, looksLikeVae, groupLabelForAsset, partnerKind).
- modelLibrarySort: pure sort/section assembly (buildProviderGroups now
  takes mode/isSearching as params instead of reading refs).
- useModelLibraryHoverPopover: the shared row→popover hover bridge.
- useModelLibraryLeaf: shared row wiring + class constants, removing the
  duplication between CloudModelLeaf and CloudPartnerLeaf.

Adds unit tests for the grouping and sorting heuristics (previously
untested), reuses partnerKind in PartnerNodeHoverPreview, and removes the
dead preview-container div.
2026-06-04 08:49:52 +12:00
shrimbly
c7873ac7ed feat: add Model Library sidebar tab (cloud + local)
Replaces the legacy TreeExplorer Model Library tab with a new browser:

- CloudModelLibrarySidebarTab with grouped cloud + local model browsing,
  category/partner leaves (CloudModelLeaf, CloudPartnerLeaf), and rich
  hover previews (AssetHoverPreview, PartnerNodeHoverPreview).
- Registration via sidebarTabStore (the legacy useModelLibrarySidebarTab
  is removed), wired through SidebarTabTemplate, useCoreCommands, and
  useCanvasDrop for drag-to-canvas.
- Tints the shared LGraphNodePreview header so node previews read as real
  canvas nodes (also improves the node-library hover cards).

Drops the foundation PR's knip stacked-PR scaffolding for the files and
exports this tab now consumes; only useRecentlyUsedModels stays ignored
for the follow-up in-node picker PR.
2026-06-04 08:34:18 +12:00
shrimbly
87625d852b feat: add model-library data foundation
Shared data layer for the Model Library feature, with no app-visible UI:

- Asset metadata/sort/filter helpers (assetService, assetMetadataUtils,
  assetSortUtils, filterTypes) extended for model browsing.
- Base-model inference with curated overrides (baseModelInference,
  baseModelOverrides, baseModelCategoryOverrides, comfyOrgProviderOverrides).
- Model grouping and provider formatting (modelGroups), category icon
  helpers (categoryUtil), and category placeholder support.
- Model Library data sources: cloud (useModelLibrarySource) and on-disk
  local (useLocalModelLibrarySource), plus useRecentlyUsedModels and the
  node-preview drag image helper.

Consumers land in stacked follow-up PRs (sidebar tab, in-node picker).
Until then their orphaned files/exports are scoped under the existing
knip stacked-PR conventions (ignore list + @knipIgnoreUsedByStackedPR),
to be removed as each consumer arrives.
2026-06-04 08:32:55 +12:00
189 changed files with 10697 additions and 3968 deletions

View File

@@ -1,38 +1,18 @@
import { expect } from '@playwright/test'
import {
AFFILIATE_FAQ_COUNT,
AFFILIATE_FAQ_PREFIX
} from '../src/components/affiliates/affiliateFaqs'
import { programDetailRows } from '../src/components/affiliates/programDetails'
import type { TranslationKey } from '../src/i18n/translations'
import { affiliateFaqs } from '../src/data/affiliateFaq'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const FIRST_FAQ_QUESTION = t(
`${AFFILIATE_FAQ_PREFIX}.1.q` as TranslationKey,
'en'
)
const FIRST_FAQ_ANSWER = t(
`${AFFILIATE_FAQ_PREFIX}.1.a` as TranslationKey,
'en'
)
const FIRST_PROGRAM_DETAIL_LABEL = t(programDetailRows[0].labelKey, 'en')
const FIRST_PROGRAM_DETAIL_VALUE = t(programDetailRows[0].valueKey, 'en')
const PROGRAM_DETAIL_TABLE_ROW_COUNT = programDetailRows.length + 1
const PATH = '/affiliates'
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
const SECTION_TESTIDS = [
'affiliate-hero',
'affiliate-trust-band',
'affiliate-how-it-works',
'affiliate-audience',
'affiliate-program-details',
'affiliate-brand-assets',
'affiliate-footer-cta'
] as const
const TERMS_PATH = '/affiliates/terms'
const FAQ_COUNT = affiliateFaqs.length
const FIRST_FAQ = affiliateFaqs[0]
const HERO_HEADING_TEXT = `${t('affiliate.hero.headingHighlight', 'en')} ${t('affiliate.hero.headingMuted', 'en')}`
const CTA_HEADING_TEXT = t('affiliate.cta.heading', 'en')
const CTA_APPLY_LABEL = t('affiliate.cta.apply', 'en')
const CTA_TERMS_LABEL = t('affiliate.cta.termsLabel', 'en')
test.describe('Affiliates landing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -41,32 +21,38 @@ test.describe('Affiliates landing — desktop @smoke', () => {
test('renders the hero heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('renders every page section in top-to-bottom order', async ({
page
}) => {
const ys: number[] = []
for (const id of SECTION_TESTIDS) {
const section = page.getByTestId(id)
await expect(section).toBeVisible()
const box = await section.boundingBox()
expect(box, `${id} bounding box`).not.toBeNull()
ys.push(box!.y)
}
const sortedYs = [...ys].sort((a, b) => a - b)
expect(ys).toEqual(sortedYs)
test('renders the closing CTA heading and apply button', async ({ page }) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const ctaHeading = ctaSection.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await expect(applyButton).toBeVisible()
await expect(applyButton).toHaveAttribute('href', APPLY_URL)
await expect(applyButton).toHaveAttribute('target', '_blank')
await expect(applyButton).toHaveAttribute('rel', 'noopener noreferrer')
})
test('renders the program details table on desktop', async ({ page }) => {
const table = page.getByTestId('affiliate-program-details-table')
await expect(table).toBeVisible()
const rows = table.getByRole('row')
await expect(rows).toHaveCount(PROGRAM_DETAIL_TABLE_ROW_COUNT)
test('CTA section links to the affiliate terms page in the same tab', async ({
page
}) => {
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
await termsLink.scrollIntoViewIfNeeded()
await expect(termsLink).toBeVisible()
await expect(termsLink).toHaveAttribute('href', TERMS_PATH)
await expect(termsLink).not.toHaveAttribute('target', '_blank')
})
})
@@ -93,42 +79,21 @@ test.describe('Affiliates landing — desktop interactions', () => {
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(AFFILIATE_FAQ_COUNT)
})
test('hero and footer CTAs target the application form in a new tab', async ({
page
}) => {
const heroCta = page.getByTestId('affiliate-hero-cta')
await expect(heroCta).toBeVisible()
await expect(heroCta).toHaveAttribute('href', APPLY_URL)
await expect(heroCta).toHaveAttribute('target', '_blank')
await expect(heroCta).toHaveAttribute('rel', 'noopener noreferrer')
const footerCta = page.getByTestId('affiliate-footer-cta-button')
await expect(footerCta).toHaveAttribute('href', APPLY_URL)
await expect(footerCta).toHaveAttribute('target', '_blank')
await expect(footerCta).toHaveAttribute('rel', 'noopener noreferrer')
})
test('footer links to the affiliate terms page as a same-tab navigation', async ({
page
}) => {
const link = page
.getByTestId('affiliate-footer-cta')
.getByRole('link', { name: /Read the affiliate program terms/i })
await expect(link).toBeVisible()
await expect(link).toBeEnabled()
await expect(link).toHaveAttribute('href', '/affiliates/terms')
await expect(link).not.toHaveAttribute('target', '_blank')
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({
page,
context
}) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
await applyButton.scrollIntoViewIfNeeded()
const popupPromise = context.waitForEvent('page')
await page.getByTestId('affiliate-hero-cta').click()
await applyButton.click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
const popupUrl = popup.url()
@@ -140,12 +105,15 @@ test.describe('Affiliates landing — desktop interactions', () => {
})
test('FAQ items toggle open and closed on click', async ({ page }) => {
const firstQuestion = page.getByRole('button', { name: FIRST_FAQ_QUESTION })
const firstQuestion = page.getByRole('button', {
name: FIRST_FAQ.question.en
})
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByText(FIRST_FAQ_ANSWER)).toBeVisible()
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
@@ -157,44 +125,24 @@ test.describe('Affiliates landing — mobile @mobile', () => {
await page.goto(PATH)
})
test('renders the hero heading and primary CTA at narrow viewports', async ({
page
}) => {
test('renders the hero heading at narrow viewports', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
).toBeVisible()
await expect(page.getByTestId('affiliate-hero-cta')).toBeVisible()
})
test('program details collapse to a stacked definition list', async ({
page
}) => {
await expect(
page.getByTestId('affiliate-program-details-table')
).toBeHidden()
const detailsList = page
.getByTestId('affiliate-program-details')
.locator('dl')
await expect(detailsList).toBeVisible()
await expect(
detailsList.getByText(FIRST_PROGRAM_DETAIL_LABEL)
).toBeVisible()
await expect(
detailsList.getByText(FIRST_PROGRAM_DETAIL_VALUE)
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
).toBeVisible()
})
test('all major sections remain visible without horizontal overflow', async ({
page
}) => {
for (const id of SECTION_TESTIDS) {
const section = page.getByTestId(id)
await expect(section).toBeVisible()
const box = await section.boundingBox()
expect(box, `${id} bounding box`).not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
}
test('closing CTA stays within the viewport width', async ({ page }) => {
const ctaHeading = page.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const box = await ctaHeading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
})
})

View File

@@ -4,6 +4,10 @@ import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const LINUX_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
@@ -52,6 +56,61 @@ test.describe('Download page @smoke', () => {
await context.close()
})
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
browser
}) => {
const context = await browser.newContext({ userAgent: LINUX_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
const macBtn = hero.locator(
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
)
await expect(macBtn).toBeVisible()
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toHaveCount(2)
await context.close()
})
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
const context = await browser.newContext({ userAgent: IPHONE_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
await expect(
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
).toBeHidden()
await expect(
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
).toBeVisible()
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 11.5811L10.2582 18.0581L20 6.05811" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 234 B

View File

@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
runway: { name: 'Runway', slug: 'runway' },
vidu: { name: 'Vidu', slug: 'vidu' },
bfl: { name: 'Flux (API)', slug: 'flux-api' },
grok: { name: 'Grok Image', slug: 'grok-image' },
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
@@ -86,6 +86,20 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
}
// Stub entries that exist only to issue 301 redirects from old slugs to
// their new canonical slugs. Keeps renames reproducible across regenerations.
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
{
slug: 'grok-image',
canonicalSlug: 'grok-imagine',
name: 'Grok Image',
displayName: 'Grok Image',
directory: 'partner_nodes',
huggingFaceUrl: '',
workflowCount: 0
}
]
function stripExt(name: string): string {
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
}
@@ -299,7 +313,8 @@ function run(): void {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`
}`,
{ cause: error }
)
}
}
@@ -367,7 +382,7 @@ function run(): void {
displayName: m.name
}))
const combined = [...apiOutput, ...output]
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(

View File

@@ -1,42 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const audienceKeys: TranslationKey[] = [
'affiliate-landing.audience.item.0',
'affiliate-landing.audience.item.1',
'affiliate-landing.audience.item.2',
'affiliate-landing.audience.item.3',
'affiliate-landing.audience.item.4'
]
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-audience"
>
<SectionHeader>
{{ t('affiliate-landing.audience.heading', locale) }}
</SectionHeader>
<ul class="mx-auto mt-12 flex max-w-3xl flex-col gap-4">
<li
v-for="key in audienceKeys"
:key="key"
class="text-primary-comfy-canvas flex items-start gap-4 text-base md:text-lg"
>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
aria-hidden="true"
>
</span>
<span>{{ t(key, locale) }}</span>
</li>
</ul>
</section>
</template>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
import { brandAssets } from './brandAssets'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-brand-assets"
>
<SectionHeader>
{{ t('affiliate-landing.assets.heading', locale) }}
<template #subtitle>
<p
class="text-primary-comfy-canvas/70 mx-auto mt-4 max-w-2xl text-base"
>
{{ t('affiliate-landing.assets.subheading', locale) }}
</p>
</template>
</SectionHeader>
<ul
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
>
<li
v-for="asset in brandAssets"
:key="asset.id"
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col overflow-hidden rounded-4xl border"
:data-testid="`affiliate-asset-${asset.id}`"
>
<div
class="bg-primary-comfy-ink/40 flex aspect-video items-center justify-center overflow-hidden p-6"
>
<img
:src="asset.preview"
:alt="t(asset.titleKey, locale)"
class="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div class="flex flex-1 flex-col gap-2 p-5">
<h3 class="text-primary-comfy-canvas text-base font-light">
{{ t(asset.titleKey, locale) }}
</h3>
<a
:href="asset.download"
:download="asset.download.split('/').pop()"
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
>
{{ t('affiliate-landing.assets.downloadLabel', locale) }}
<span aria-hidden="true"></span>
</a>
</div>
</li>
</ul>
</section>
</template>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<section
class="bg-secondary-mauve/30 border-primary-comfy-canvas/10 mt-12 border-t px-6 py-20 text-center md:py-28"
data-testid="affiliate-footer-cta"
>
<h2
class="text-primary-comfy-canvas text-3xl font-light md:text-4xl lg:text-5xl"
>
{{ t('affiliate-landing.footerCta.heading', locale) }}
</h2>
<div class="mt-8 flex flex-col items-center gap-4">
<BrandButton
:href="externalLinks.affiliateApplicationForm"
target="_blank"
rel="noopener noreferrer"
size="lg"
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
data-testid="affiliate-footer-cta-button"
class="px-8 py-4 text-base"
>
{{ t('affiliate-landing.cta.apply', locale) }}
</BrandButton>
<a
:href="routes.affiliateTerms"
class="text-primary-comfy-canvas/70 text-sm underline-offset-4 hover:underline"
>
{{ t('affiliate-landing.footerCta.termsLink', locale) }}
</a>
</div>
</section>
</template>

View File

@@ -1,84 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } 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 }>()
const highlightKeys: TranslationKey[] = [
'affiliate-landing.hero.highlight.0',
'affiliate-landing.hero.highlight.1',
'affiliate-landing.hero.highlight.2',
'affiliate-landing.hero.highlight.3'
]
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-stretch gap-10 px-6 pt-12 pb-16 lg:flex-row lg:items-center lg:gap-16 lg:px-20 lg:pt-20 lg:pb-24"
data-testid="affiliate-hero"
>
<div class="flex-1">
<h1
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
>
{{ t('affiliate-landing.hero.heading', locale) }}
</h1>
<p
class="text-primary-comfy-yellow mt-4 text-2xl font-light md:text-3xl lg:text-4xl"
>
{{ t('affiliate-landing.hero.subheading', locale) }}
</p>
<p class="text-primary-comfy-canvas/80 mt-6 max-w-xl text-base">
{{ t('affiliate-landing.hero.body', locale) }}
</p>
<ul class="mt-6 flex flex-col gap-3">
<li
v-for="key in highlightKeys"
:key="key"
class="text-primary-comfy-canvas flex items-start gap-3 text-base"
>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
aria-hidden="true"
>
</span>
<span>{{ t(key, locale) }}</span>
</li>
</ul>
<div class="mt-8">
<BrandButton
:href="externalLinks.affiliateApplicationForm"
target="_blank"
rel="noopener noreferrer"
size="lg"
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
data-testid="affiliate-hero-cta"
class="px-8 py-4 text-base"
>
{{ t('affiliate-landing.cta.apply', locale) }}
</BrandButton>
</div>
</div>
<div
class="flex flex-1 items-center justify-center"
data-testid="affiliate-hero-media"
>
<slot name="media">
<video
src="https://media.comfy.org/website/homepage/showcase/ui-overview.webm"
autoplay
loop
muted
playsinline
aria-hidden="true"
class="w-full max-w-xl rounded-4xl"
/>
</slot>
</div>
</section>
</template>

View File

@@ -1,55 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps: { titleKey: TranslationKey; bodyKey: TranslationKey }[] = [
{
titleKey: 'affiliate-landing.how.step.0.title',
bodyKey: 'affiliate-landing.how.step.0.body'
},
{
titleKey: 'affiliate-landing.how.step.1.title',
bodyKey: 'affiliate-landing.how.step.1.body'
},
{
titleKey: 'affiliate-landing.how.step.2.title',
bodyKey: 'affiliate-landing.how.step.2.body'
}
]
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-how-it-works"
>
<SectionHeader>
{{ t('affiliate-landing.how.heading', locale) }}
</SectionHeader>
<ol
class="mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3 md:gap-8"
>
<li
v-for="(step, index) in steps"
:key="step.titleKey"
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col gap-4 rounded-4xl border p-8"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ index + 1 }}
</span>
<h3 class="text-primary-comfy-canvas text-2xl font-light">
{{ t(step.titleKey, locale) }}
</h3>
<p class="text-primary-comfy-canvas/70 text-sm">
{{ t(step.bodyKey, locale) }}
</p>
</li>
</ol>
</section>
</template>

View File

@@ -1,79 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionHeader from '../common/SectionHeader.vue'
import { programDetailRows } from './programDetails'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="px-6 py-20 md:px-20 md:py-28"
data-testid="affiliate-program-details"
>
<SectionHeader>
{{ t('affiliate-landing.details.heading', locale) }}
</SectionHeader>
<div class="mx-auto mt-12 max-w-3xl">
<div class="hidden md:block">
<table
class="w-full border-collapse text-left"
data-testid="affiliate-program-details-table"
>
<thead>
<tr class="border-primary-comfy-canvas/20 border-b">
<th
scope="col"
class="text-primary-comfy-yellow py-4 pr-6 text-xs font-bold tracking-widest uppercase"
>
{{ t('affiliate-landing.details.headerLabel', locale) }}
</th>
<th
scope="col"
class="text-primary-comfy-yellow py-4 text-xs font-bold tracking-widest uppercase"
>
{{ t('affiliate-landing.details.headerValue', locale) }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in programDetailRows"
:key="row.labelKey"
class="border-primary-comfy-canvas/10 border-b"
>
<th
scope="row"
class="text-primary-comfy-canvas py-5 pr-6 text-base font-light"
>
{{ t(row.labelKey, locale) }}
</th>
<td class="text-primary-comfy-canvas/80 py-5 text-base">
{{ t(row.valueKey, locale) }}
</td>
</tr>
</tbody>
</table>
</div>
<dl class="flex flex-col gap-6 md:hidden">
<div
v-for="row in programDetailRows"
:key="row.labelKey"
class="border-primary-comfy-canvas/10 flex flex-col gap-1 border-b pb-4"
>
<dt
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t(row.labelKey, locale) }}
</dt>
<dd class="text-primary-comfy-canvas text-base">
{{ t(row.valueKey, locale) }}
</dd>
</div>
</dl>
</div>
</section>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="border-primary-comfy-canvas/10 border-y px-6 py-10 text-center md:py-12"
data-testid="affiliate-trust-band"
>
<p
class="text-primary-comfy-canvas text-sm tracking-wider uppercase md:text-base"
>
{{ t('affiliate-landing.trust.label', locale) }}
</p>
</section>
</template>

View File

@@ -1,6 +0,0 @@
import type { TranslationKey } from '../../i18n/translations'
export const AFFILIATE_FAQ_PREFIX = 'affiliate-landing.faq'
export const AFFILIATE_FAQ_HEADING_KEY: TranslationKey =
'affiliate-landing.faq.heading'
export const AFFILIATE_FAQ_COUNT = 8

View File

@@ -1,154 +0,0 @@
import { describe, expect, it } from 'vitest'
import { hasKey, t, translationKeys } from '../../i18n/translations'
import {
AFFILIATE_FAQ_COUNT,
AFFILIATE_FAQ_HEADING_KEY,
AFFILIATE_FAQ_PREFIX
} from './affiliateFaqs'
import { brandAssets } from './brandAssets'
const PREFIX = 'affiliate-landing'
const EXPECTED_SECTION_PREFIXES = [
'page',
'cta',
'hero',
'trust',
'how',
'audience',
'details',
'assets',
'faq',
'footerCta'
] as const
const HERO_HIGHLIGHT_COUNT = 4
const HOW_STEP_COUNT = 3
const AUDIENCE_ITEM_COUNT = 5
const DETAILS_ROW_COUNT = 6
const INTERNAL_KEY_PATTERNS = [
/open-questions/,
/todo/i,
/draft/i,
/placeholder/i,
/internal/i
]
function affiliateKeys(): string[] {
return translationKeys.filter((k) => k.startsWith(`${PREFIX}.`))
}
describe('affiliate landing i18n', () => {
it('exposes the canonical top-level section prefixes', () => {
const keys = affiliateKeys()
for (const section of EXPECTED_SECTION_PREFIXES) {
const hit = keys.some((k) => k.startsWith(`${PREFIX}.${section}.`))
expect(hit, `missing section: ${section}`).toBe(true)
}
})
it('orders sections as the page renders them', () => {
const keys = affiliateKeys()
const seenSections: string[] = []
for (const key of keys) {
const section = key.split('.')[1]
if (!section) continue
if (!seenSections.includes(section)) seenSections.push(section)
}
const orderedExpected = EXPECTED_SECTION_PREFIXES.filter((s) =>
seenSections.includes(s)
)
const orderedActual = seenSections.filter((s) =>
(EXPECTED_SECTION_PREFIXES as readonly string[]).includes(s)
)
expect(orderedActual).toEqual([...orderedExpected])
})
it('exposes hero, page, and cta keys editors will need', () => {
expect(hasKey(`${PREFIX}.page.title`)).toBe(true)
expect(hasKey(`${PREFIX}.page.description`)).toBe(true)
expect(hasKey(`${PREFIX}.cta.apply`)).toBe(true)
expect(hasKey(`${PREFIX}.cta.applyAriaLabel`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.subheading`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.body`)).toBe(true)
for (let i = 0; i < HERO_HIGHLIGHT_COUNT; i++) {
expect(hasKey(`${PREFIX}.hero.highlight.${i}`)).toBe(true)
}
})
it('exposes the trust band, how-it-works, and audience copy', () => {
expect(hasKey(`${PREFIX}.trust.label`)).toBe(true)
expect(hasKey(`${PREFIX}.how.heading`)).toBe(true)
for (let i = 0; i < HOW_STEP_COUNT; i++) {
expect(hasKey(`${PREFIX}.how.step.${i}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.how.step.${i}.body`)).toBe(true)
}
expect(hasKey(`${PREFIX}.audience.heading`)).toBe(true)
for (let i = 0; i < AUDIENCE_ITEM_COUNT; i++) {
expect(hasKey(`${PREFIX}.audience.item.${i}`)).toBe(true)
}
})
it('exposes the program details rows', () => {
expect(hasKey(`${PREFIX}.details.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.details.headerLabel`)).toBe(true)
expect(hasKey(`${PREFIX}.details.headerValue`)).toBe(true)
for (let i = 0; i < DETAILS_ROW_COUNT; i++) {
expect(hasKey(`${PREFIX}.details.row.${i}.label`)).toBe(true)
expect(hasKey(`${PREFIX}.details.row.${i}.value`)).toBe(true)
}
})
it('matches every brand-asset tile to a translation key', () => {
expect(hasKey(`${PREFIX}.assets.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.assets.subheading`)).toBe(true)
expect(hasKey(`${PREFIX}.assets.downloadLabel`)).toBe(true)
for (const asset of brandAssets) {
expect(hasKey(asset.titleKey)).toBe(true)
}
})
it('exposes every 1-indexed faq.<n>.q/a pair from 1 to AFFILIATE_FAQ_COUNT (FAQSection contract)', () => {
expect(AFFILIATE_FAQ_PREFIX).toBe(`${PREFIX}.faq`)
expect(hasKey(AFFILIATE_FAQ_HEADING_KEY)).toBe(true)
for (let n = 1; n <= AFFILIATE_FAQ_COUNT; n++) {
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.q`)).toBe(true)
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.a`)).toBe(true)
}
})
it('keeps AFFILIATE_FAQ_COUNT in sync with the actual faq.<n>.q keys in translations', () => {
const faqQuestionKeyPattern = new RegExp(
`^${AFFILIATE_FAQ_PREFIX}\\.(\\d+)\\.q$`
)
const indices = translationKeys
.map((k) => k.match(faqQuestionKeyPattern)?.[1])
.filter((m): m is string => m !== undefined)
.map((s) => parseInt(s, 10))
.sort((a, b) => a - b)
expect(indices).toEqual(
Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => i + 1)
)
})
it('exposes the footer cta copy', () => {
expect(hasKey(`${PREFIX}.footerCta.heading`)).toBe(true)
expect(hasKey(`${PREFIX}.footerCta.termsLink`)).toBe(true)
})
it('returns non-empty english copy for every affiliate-landing key', () => {
for (const key of affiliateKeys()) {
expect(t(key as never, 'en').trim().length).toBeGreaterThan(0)
}
})
it('does not leak internal-only keys (drafts, todos, open questions)', () => {
const leaks = affiliateKeys().filter((k) =>
INTERNAL_KEY_PATTERNS.some((re) => re.test(k))
)
expect(leaks).toEqual([])
})
})

View File

@@ -1,59 +0,0 @@
import type { TranslationKey } from '../../i18n/translations'
interface BrandAsset {
id: string
titleKey: TranslationKey
download: string
preview: string
}
export const brandAssets: BrandAsset[] = [
{
id: 'logo-horizontal',
titleKey: 'affiliate-landing.assets.tile.logo-horizontal.title',
download: '/icons/logo.svg',
preview: '/icons/logo.svg'
},
{
id: 'logomark',
titleKey: 'affiliate-landing.assets.tile.logomark.title',
download: '/icons/logomark.svg',
preview: '/icons/logomark.svg'
},
{
id: 'comfy-full-logo-yellow',
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-yellow.title',
download: '/affiliates/brand/comfy-full-logo-yellow.svg',
preview: '/affiliates/brand/comfy-full-logo-yellow.svg'
},
{
id: 'comfy-full-logo-ink',
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-ink.title',
download: '/affiliates/brand/comfy-full-logo-ink.svg',
preview: '/affiliates/brand/comfy-full-logo-ink.svg'
},
{
id: 'amplified-logo-mark',
titleKey: 'affiliate-landing.assets.tile.amplified-logo-mark.title',
download: '/affiliates/brand/comfy-amplified-logo-mark.svg',
preview: '/affiliates/brand/comfy-amplified-logo-mark.svg'
},
{
id: 'dimensional-logo-mark',
titleKey: 'affiliate-landing.assets.tile.dimensional-logo-mark.title',
download: '/affiliates/brand/comfy-dimensional-logo-mark.svg',
preview: '/affiliates/brand/comfy-dimensional-logo-mark.svg'
},
{
id: 'color-combo-yellow',
titleKey: 'affiliate-landing.assets.tile.color-combo-yellow.title',
download: '/affiliates/brand/comfy-color-combo-yellow.svg',
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
},
{
id: 'color-combo-ink',
titleKey: 'affiliate-landing.assets.tile.color-combo-ink.title',
download: '/affiliates/brand/comfy-color-combo-ink.svg',
preview: '/affiliates/brand/comfy-color-combo-ink.svg'
}
]

View File

@@ -1,33 +0,0 @@
import type { TranslationKey } from '../../i18n/translations'
interface ProgramDetailRow {
labelKey: TranslationKey
valueKey: TranslationKey
}
export const programDetailRows: ProgramDetailRow[] = [
{
labelKey: 'affiliate-landing.details.row.0.label',
valueKey: 'affiliate-landing.details.row.0.value'
},
{
labelKey: 'affiliate-landing.details.row.1.label',
valueKey: 'affiliate-landing.details.row.1.value'
},
{
labelKey: 'affiliate-landing.details.row.2.label',
valueKey: 'affiliate-landing.details.row.2.value'
},
{
labelKey: 'affiliate-landing.details.row.3.label',
valueKey: 'affiliate-landing.details.row.3.value'
},
{
labelKey: 'affiliate-landing.details.row.4.label',
valueKey: 'affiliate-landing.details.row.4.value'
},
{
labelKey: 'affiliate-landing.details.row.5.label',
valueKey: 'affiliate-landing.details.row.5.value'
}
]

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import BrandButton from '../common/BrandButton.vue'
import GlassCard from '../common/GlassCard.vue'
type Benefit = { id: string; description: string }
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
}
defineProps<{
heading: string
benefits: readonly Benefit[]
primaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ heading }}
</h2>
<GlassCard class="mx-auto max-w-7xl">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4">
<article
v-for="(benefit, index) in benefits"
:key="benefit.id"
class="flex flex-col gap-6 rounded-4xl bg-primary-comfy-ink p-6 lg:p-8"
>
<span
class="text-primary-comfy-yellow font-mono text-sm font-bold tracking-wide"
>
{{ String(index + 1).padStart(2, '0') }}
</span>
<p
class="text-base/relaxed font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ benefit.description }}
</p>
</article>
</div>
</GlassCard>
<div v-if="primaryCta" class="mt-10 flex justify-center lg:mt-12">
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
size="lg"
class="px-20 py-4 text-base uppercase"
variant="outline"
>
{{ primaryCta.label }}
</BrandButton>
</div>
</section>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
type Asset = {
id: string
title: string
download: string
preview: string
}
defineProps<{
heading: string
subheading: string
downloadLabel: string
assets: readonly Asset[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="mx-auto max-w-6xl text-center">
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ heading }}
</h2>
<p class="mx-auto mt-4 max-w-2xl text-base text-primary-comfy-canvas/70">
{{ subheading }}
</p>
</div>
<ul
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
>
<li
v-for="asset in assets"
:key="asset.id"
class="bg-transparency-white-t4 flex flex-col overflow-hidden rounded-4xl border border-primary-comfy-canvas/10"
>
<div
class="flex aspect-video items-center justify-center overflow-hidden bg-primary-comfy-ink/40 p-6"
>
<img
:src="asset.preview"
:alt="asset.title"
class="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div class="flex flex-1 flex-col gap-2 p-5">
<h3 class="text-base font-light text-primary-comfy-canvas">
{{ asset.title }}
</h3>
<a
:href="asset.download"
:download="asset.download.split('/').pop()"
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
>
{{ downloadLabel }}
<span aria-hidden="true"></span>
</a>
</div>
</li>
</ul>
</section>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import GlassCard from '../common/GlassCard.vue'
import CheckIcon from '../icons/CheckIcon.vue'
type Criterion = { id: string; label: string }
defineProps<{
heading: string
subheading: string
eyebrow?: string
criteria: readonly Criterion[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ heading }}
</h2>
<GlassCard class="px-6 py-10 lg:px-16 lg:py-14">
<div
class="grid grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-16"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-4xl">
{{ subheading }}
</h3>
<div class="flex flex-col gap-6">
<span
v-if="eyebrow"
class="text-xs font-bold tracking-widest text-primary-comfy-canvas uppercase"
>
{{ eyebrow }}
</span>
<ul class="flex flex-col gap-4">
<li
v-for="criterion in criteria"
:key="criterion.id"
class="flex items-start gap-3"
>
<CheckIcon
class="text-primary-comfy-yellow mt-0.5 size-5 shrink-0"
/>
<span class="text-sm text-primary-comfy-canvas lg:text-base">
{{ criterion.label }}
</span>
</li>
</ul>
</div>
</div>
</GlassCard>
</section>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import BrandButton from '../common/BrandButton.vue'
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
}
type TermsLink = {
label: string
href: string
}
defineProps<{
heading: string
primaryCta: Cta
termsLink: TermsLink
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
>
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ heading }}
</h2>
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
variant="outline"
size="lg"
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
>
{{ primaryCta.label }}
</BrandButton>
<a
:href="termsLink.href"
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
>
{{ termsLink.label }}
</a>
</section>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
const { faqs } = defineProps<{
heading: string
faqs: readonly Faq[]
}>()
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
() => faqs.length,
(length) => {
if (length === expanded.length) return
expanded.length = 0
for (let i = 0; i < length; i += 1) expanded.push(false)
}
)
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
{{ heading }}
</h2>
</div>
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-primary-comfy-canvas/20 border-b"
>
<button
:id="`faq-trigger-${faq.id}`"
type="button"
:aria-expanded="expanded[index]"
:aria-controls="`faq-panel-${faq.id}`"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between text-left',
index === 0 ? 'pb-6' : 'py-6'
)
"
@click="toggle(index)"
>
<span
:class="
cn(
'text-lg font-light md:text-xl',
expanded[index]
? 'text-primary-comfy-yellow'
: 'text-primary-comfy-canvas'
)
"
>
{{ faq.question }}
</span>
<span
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
aria-hidden="true"
>
{{ expanded[index] ? '' : '+' }}
</span>
</button>
<section
v-show="expanded[index]"
:id="`faq-panel-${faq.id}`"
role="region"
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
{{ faq.answer }}
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import CheckIcon from '../icons/CheckIcon.vue'
type Cta = {
label: string
href: string
target?: '_blank' | '_self' | '_parent' | '_top'
}
type VideoTrack = {
src: string
kind: 'subtitles' | 'captions' | 'descriptions'
srclang: string
label: string
}
const {
locale = 'en',
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
titleHighlight,
features = [],
primaryCta,
secondaryCta,
imageSrc,
imageAlt = '',
imageWidth = 800,
imageHeight = 600,
imagePosition = 'right',
videoSrc,
videoPoster,
videoTracks = [],
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false
} = defineProps<{
locale?: Locale
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
imageSrc?: string
imageAlt?: string
imageWidth?: number
imageHeight?: number
imagePosition?: 'left' | 'right'
videoSrc?: string
videoPoster?: string
videoTracks?: VideoTrack[]
videoAutoplay?: boolean
videoLoop?: boolean
videoMinimal?: boolean
videoHideControls?: boolean
}>()
</script>
<template>
<section
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
)
"
>
<div class="w-full lg:flex-1">
<ProductHeroBadge
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
{{ title }}
</template>
<template v-else>{{ title }}</template>
</h1>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
:key="feature"
class="flex items-start gap-3 text-base text-primary-comfy-canvas"
>
<CheckIcon class="text-primary-comfy-yellow mt-1 size-5 shrink-0" />
{{ feature }}
</li>
</ul>
<div class="mt-10 flex flex-col gap-4 sm:flex-row">
<BrandButton
:href="primaryCta.href"
:target="primaryCta.target"
size="lg"
class="px-8 py-4 text-base uppercase"
>
{{ primaryCta.label }}
</BrandButton>
<BrandButton
v-if="secondaryCta"
:href="secondaryCta.href"
:target="secondaryCta.target"
variant="outline"
size="lg"
class="px-8 py-4 text-base uppercase"
>
{{ secondaryCta.label }}
</BrandButton>
</div>
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</div>
</section>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Step = { id: string; label: string; description: string }
defineProps<{
heading: string
steps: readonly Step[]
}>()
const isRtlRow = (i: number) => Math.floor(i / 2) % 2 === 1
const isFullSpan = (i: number, total: number) =>
i === total - 1 && total % 2 === 1
function hasHorizontalConnector(i: number, total: number) {
if (isFullSpan(i, total)) return false
if (!isRtlRow(i) && i % 2 === 0 && i + 1 < total) return true
if (isRtlRow(i) && i % 2 === 1) return true
return false
}
function hasMobileVertical(i: number, total: number) {
return i < total - 1
}
function hasLgVertical(i: number, total: number) {
return i % 2 === 1 && i + 1 < total
}
function cardClass(i: number, total: number) {
const fullSpan = isFullSpan(i, total)
const rtl = isRtlRow(i)
return cn(
'border-primary-comfy-yellow relative rounded-3xl border-2 p-8 lg:p-10',
fullSpan && 'lg:col-span-2',
!fullSpan && rtl && i % 2 === 0 && 'lg:col-start-2',
!fullSpan && rtl && i % 2 === 1 && 'lg:col-start-1'
)
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ heading }}
</h2>
<div
class="mx-auto grid max-w-3xl grid-cols-1 gap-4 lg:grid-flow-dense lg:grid-cols-2"
>
<div
v-for="(step, index) in steps"
:key="step.id"
:class="cardClass(index, steps.length)"
>
<span
class="bg-primary-comfy-yellow font-formula-narrow inline-block -skew-x-12 rounded-sm px-3 py-1.5 text-sm font-bold tracking-wide text-primary-comfy-ink uppercase lg:text-base"
>
<span class="inline-block skew-x-12">
{{ index + 1 }}. {{ step.label }}
</span>
</span>
<p class="mt-6 text-sm/relaxed text-primary-comfy-canvas lg:text-base">
{{ step.description }}
</p>
<NodeUnionIcon
v-if="hasHorizontalConnector(index, steps.length)"
class="text-primary-comfy-yellow absolute top-1/2 right-0 hidden size-4 translate-x-[calc(100%+2px)] -translate-y-1/2 scale-x-150 rotate-90 lg:block"
/>
<NodeUnionIcon
v-if="
hasMobileVertical(index, steps.length) ||
hasLgVertical(index, steps.length)
"
:class="
cn(
'text-primary-comfy-yellow absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-[calc(100%+2px)] scale-x-150',
!hasMobileVertical(index, steps.length) && 'hidden lg:block',
!hasLgVertical(index, steps.length) && 'lg:hidden'
)
"
/>
</div>
</div>
</section>
</template>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type { Locale, TranslationKey } from '../../i18n/translations'
import WireNodeLayout from '../common/WireNodeLayout.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasons = [
const reasons: TranslationKey[] = [
'careers.whyJoin.reason1',
'careers.whyJoin.reason2',
'careers.whyJoin.reason3',
'careers.whyJoin.reason4',
'careers.whyJoin.reason5'
] as const
]
</script>
<template>

View File

@@ -1,19 +1,13 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { BrandButtonVariants } from './brandButton.variants'
import { brandButtonVariants } from './brandButton.variants'
const {
href,
target,
rel,
variant,
size,
class: customClass = ''
} = defineProps<{
const props = defineProps<{
href?: string
target?: string
rel?: string
@@ -21,15 +15,25 @@ const {
size?: BrandButtonVariants['size']
class?: HTMLAttributes['class']
}>()
const resolvedRel = computed(
() =>
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
)
</script>
<template>
<component
:is="href ? 'a' : 'button'"
:href
:target
:rel
:class="cn(brandButtonVariants({ variant, size }), customClass)"
:is="props.href ? 'a' : 'button'"
:href="props.href"
:target="props.target"
:rel="resolvedRel"
:class="
cn(
brandButtonVariants({ variant: props.variant, size: props.size }),
props.class ?? ''
)
"
>
<span class="ppformula-text-center">
<slot />

View File

@@ -28,14 +28,18 @@ const {
poster,
tracks = [],
autoplay = false,
minimal = false
loop = false,
minimal = false,
hideControls = false
} = defineProps<{
locale?: Locale
src?: string
poster?: string
tracks?: VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -200,8 +204,9 @@ function toggleFullscreen() {
crossorigin="anonymous"
playsinline
:autoplay
:loop
muted
@click="playing = !playing"
@click="hideControls ? undefined : (playing = !playing)"
>
<track
v-for="track in tracks"
@@ -215,7 +220,7 @@ function toggleFullscreen() {
<!-- Minimal centered play/pause button -->
<div
v-if="minimal && src"
v-if="minimal && src && !hideControls"
:class="
cn(
'absolute inset-0 flex items-center justify-center transition-opacity duration-300',
@@ -235,7 +240,7 @@ function toggleFullscreen() {
<!-- Bottom control bar -->
<div
v-if="src && !minimal"
v-if="src && !minimal && !hideControls"
:class="
cn(
'absolute inset-x-0 bottom-0 flex items-center gap-3 p-4 transition-opacity duration-300 lg:px-6 lg:py-5',
@@ -285,7 +290,7 @@ function toggleFullscreen() {
@click="toggleFullscreen"
>
<svg
class="text-primary-comfy-ink size-3.5 lg:size-4"
class="size-3.5 text-primary-comfy-ink lg:size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -331,7 +336,7 @@ function toggleFullscreen() {
<!-- Muted icon -->
<svg
v-if="muted"
class="text-primary-comfy-ink size-3.5 lg:size-4"
class="size-3.5 text-primary-comfy-ink lg:size-4"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
@@ -349,7 +354,7 @@ function toggleFullscreen() {
<!-- Unmuted icon -->
<svg
v-else
class="text-primary-comfy-ink size-3.5 lg:size-4"
class="size-3.5 text-primary-comfy-ink lg:size-4"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"

View File

@@ -7,12 +7,16 @@ const {
item,
locale = 'en',
aspect = 'var(--aspect-ratio-gallery-card)',
mobile = false
mobile = false,
objectPosition = 'center',
objectFit = 'cover'
} = defineProps<{
item: GalleryItem
locale?: Locale
aspect?: string
mobile?: boolean
objectPosition?: string
objectFit?: string
}>()
defineEmits<{ click: [] }>()
@@ -31,13 +35,15 @@ defineEmits<{ click: [] }>()
loop
muted
playsinline
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
/>
<img
v-else
:src="item.image"
:alt="item.title"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
/>
<!-- Desktop hover overlay -->
<div

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
:class="$props.class"
>
<path
d="M5 11.5811L10.2582 18.0581L20 6.05811"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useId } from 'vue'
defineProps<{ class?: HTMLAttributes['class'] }>()
const clipId = `node-union-icon-clip-${useId()}`
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
fill="none"
aria-hidden="true"
:class="$props.class"
>
<g :clip-path="`url(#${clipId})`">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M-1.59144e-05 0H100V100H-1.59144e-05V0ZM32.3741 50C32.3741 77.0727 16.2692 99.0196 -3.59714 99.0196C-23.4635 99.0196 -39.5684 77.0727 -39.5684 50C-39.5684 22.9273 -23.4635 0.980392 -3.59714 0.980392C16.2692 0.980392 32.3741 22.9273 32.3741 50ZM139.568 50C139.568 77.0727 123.463 99.0196 103.597 99.0196C83.7309 99.0196 67.6259 77.0727 67.6259 50C67.6259 22.9273 83.7309 0.980392 103.597 0.980392C123.463 0.980392 139.568 22.9273 139.568 50Z"
/>
</g>
<defs>
<clipPath :id="clipId">
<rect width="100" height="100" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@@ -21,7 +21,7 @@ const demoVideoPoster =
<div class="flex flex-col gap-8">
<div>
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('learning.featured.title', locale) }}
</h2>
@@ -31,7 +31,7 @@ const demoVideoPoster =
</div>
<p
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
class="max-w-md text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t('learning.featured.description', locale) }}
</p>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
@@ -15,21 +16,6 @@ const { tutorial, locale = 'en' } = defineProps<{
const emit = defineEmits<{ close: [] }>()
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const playFromStart = () => {
const video = videoRef.value
if (!video) return
video.currentTime = 0
void video.play().catch(() => {})
}
watch(
() => tutorial.id,
() => {
playFromStart()
}
)
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) emit('close')
@@ -42,7 +28,6 @@ function handleKeydown(e: KeyboardEvent) {
onMounted(() => {
lockScroll()
dialogRef.value?.showModal()
playFromStart()
})
onUnmounted(() => {
@@ -62,31 +47,30 @@ onUnmounted(() => {
>
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
<div
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
>
<video
ref="videoRef"
<VideoPlayer
:key="tutorial.id"
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
controls
autoplay
playsinline
></video>
class="w-full"
/>
</div>
<h2
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}

View File

@@ -22,7 +22,7 @@ const activeTutorial = () =>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
class="mb-12 text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
>
{{ t('learning.tutorials.heading', locale) }}
</h2>
@@ -71,9 +71,9 @@ const activeTutorial = () =>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
id: 'subway-swan',
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
title: 'Subway Swan',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'milos-little-wonder',
video:
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
title: 'Milos Little Wonder',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'amber-passage',
image:
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
title: 'Amber Passage',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats',
objectPosition: 'bottom'
},
{
id: 'neon-revenant',
video:
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
title: 'Neon Revenant',
userAlias: 'Eric Solorio',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/enigmatic_e'
},
{
id: 'midnight-umami',
image:
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
title: 'Midnight Umami',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)
function openDetail(index: number) {
modalIndex.value = index
modalOpen.value = true
}
const title = t('models.list.creations.title', locale).replace(
'{name}',
modelName
)
const ctaLabel = t('models.list.creations.cta', locale)
</script>
<template>
<section
data-testid="model-creations"
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
>
<h2
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ title }}
</h2>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-16 px-8 py-4 uppercase"
>
{{ ctaLabel }}
</BrandButton>
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
<div class="grid grid-cols-2 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(0, 2)"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i)"
/>
</div>
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(2, 5)"
:key="i + 2"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i + 2)"
/>
</div>
</div>
<div
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
>
<GalleryCard
v-for="(item, i) in items"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
mobile
@click="openDetail(i)"
/>
</div>
<GalleryDetailModal
v-if="modalOpen"
:items
:initial-index="modalIndex"
:locale
@close="modalOpen = false"
/>
</section>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const {
locale = 'en',
modelName,
ctaHref,
videoSrc,
videoAriaLabel
} = defineProps<{
locale?: Locale
modelName: string
ctaHref: string
videoSrc: string
videoAriaLabel?: string
}>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
<h1
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{
t('models.list.heroTitle.before', locale).replace('{name}', modelName)
}}
<span class="text-primary-comfy-yellow">ComfyUI</span>
{{
t('models.list.heroTitle.after', locale).replace('{name}', modelName)
}}
</h1>
<p
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-10 px-8 py-4 uppercase"
>
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
</BrandButton>
<div class="mt-16 w-full max-w-5xl">
<video
:src="videoSrc"
:aria-label="videoAriaLabel || undefined"
:aria-hidden="videoAriaLabel ? undefined : true"
autoplay
loop
muted
playsinline
preload="metadata"
class="rounded-4.5xl size-full object-cover"
/>
</div>
</section>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CheckIcon from '../icons/CheckIcon.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -94,12 +95,9 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<img
<CheckIcon
v-else
src="/icons/check.svg"
alt=""
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}

View File

@@ -3,7 +3,10 @@ import type { Locale } from '../../../i18n/translations'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import {
downloadUrls,
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
@@ -12,32 +15,64 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { downloadUrl, platform } = useDownloadUrl()
const { downloadUrl, platform, showFallback } = useDownloadUrl()
const iconSrc = computed(() => {
switch (platform.value) {
case 'mac':
return '/icons/os/apple.svg'
case 'windows':
return '/icons/os/windows.svg'
default:
return undefined
const ICONS = {
windows: '/icons/os/windows.svg',
mac: '/icons/os/apple.svg'
} as const
interface ButtonSpec {
key: string
href: string
icon: string
ariaLabel?: string
}
const buttons = computed<ButtonSpec[]>(() => {
if (platform.value) {
return [
{
key: platform.value,
href: downloadUrl.value,
icon: ICONS[platform.value]
}
]
}
if (showFallback.value) {
const label = t('download.hero.downloadLocal', locale)
return [
{
key: 'windows',
href: downloadUrls.windows,
icon: ICONS.windows,
ariaLabel: `${label} — Windows`
},
{
key: 'mac',
href: downloadUrls.macArm,
icon: ICONS.mac,
ariaLabel: `${label} — macOS`
}
]
}
return []
})
</script>
<template>
<BrandButton
v-show="platform"
:href="downloadUrl"
v-for="btn in buttons"
:key="btn.key"
:href="btn.href"
target="_blank"
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
>
<span class="inline-flex items-center gap-2">
<img
v-if="iconSrc"
:src="iconSrc"
:src="btn.icon"
alt=""
class="ppformula-text-center size-5 -translate-y-0.75"
aria-hidden="true"

View File

@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
</p>
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
>
{{ t('cloud.aiModels.heading', locale) }}
</h2>
<p
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('cloud.aiModels.subtitle', locale) }}
</p>
<div class="mt-24 w-full">
<div class="mt-16 w-full lg:mt-24">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
@@ -180,14 +180,15 @@ function getCardClass(layoutClass: string): string {
<BrandButton
:href="externalLinks.workflows"
variant="outline"
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
size="lg"
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
>
<span class="lg:hidden">{{
t('cloud.aiModels.ctaMobile', locale)
}}</span>
<span class="hidden lg:inline">{{
<!-- <span class="lg:hidden"> -->
{{ t('cloud.aiModels.ctaMobile', locale) }}
<!-- </span> -->
<!-- <span class="hidden lg:inline">{{
t('cloud.aiModels.ctaDesktop', locale)
}}</span>
}}</span> -->
</BrandButton>
</div>
</section>

View File

@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
const downloadUrls = {
export const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
@@ -24,6 +24,8 @@ function detectPlatform(ua: string): DetectedPlatform {
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<DetectedPlatform>(null)
const detected = ref(false)
const isMobileUa = ref(false)
const downloadUrl = computed(() => {
if (platform.value === 'windows') return downloadUrls.windows
@@ -31,9 +33,16 @@ export function useDownloadUrl() {
return externalLinks.github
})
const showFallback = computed(
() => detected.value && !platform.value && !isMobileUa.value
)
onMounted(() => {
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
const ua = navigator.userAgent.toLowerCase()
isMobileUa.value = isMobile(ua)
platform.value = detectPlatform(ua)
detected.value = true
})
return { downloadUrl, platform }
return { downloadUrl, platform, showFallback }
}

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
hubSlug: 'seedance',
featured: true
},
'grok-image': {
'grok-imagine': {
hubSlug: 'grok',
featured: false
},

View File

@@ -0,0 +1,44 @@
import type { LocalizedText } from '../i18n/translations'
interface AudienceCriterion {
id: string
label: LocalizedText
}
export const affiliateAudienceCriteria: readonly AudienceCriterion[] = [
{
id: 'tutorial-creator',
label: {
en: 'A ComfyUI tutorial creator or workflow builder',
'zh-CN': 'ComfyUI 教程作者或工作流创建者'
}
},
{
id: 'ai-tool-reviewer',
label: {
en: 'An AI tool reviewer on YouTube, TikTok, blogs',
'zh-CN': '在 YouTube、TikTok、博客上做 AI 工具测评'
}
},
{
id: 'tech-blogger',
label: {
en: 'A tech blogger covering AI creative tools',
'zh-CN': '报道 AI 创作工具的科技博主'
}
},
{
id: 'newsletter-operator',
label: {
en: 'A newsletter operator in the AI/creative space',
'zh-CN': 'AI创意领域的简报运营者'
}
},
{
id: 'audience-owner',
label: {
en: 'Anyone with an audience interested in AI image, video, or 3D',
'zh-CN': '拥有关注 AI 图像、视频或 3D 受众的任何人'
}
}
] as const

View File

@@ -0,0 +1,39 @@
import type { LocalizedText } from '../i18n/translations'
interface AffiliateBenefit {
id: string
description: LocalizedText
}
export const affiliateBenefits: readonly AffiliateBenefit[] = [
{
id: 'open-source-platform',
description: {
en: 'ComfyUI is the most powerful open-source AI creative platform',
'zh-CN': 'ComfyUI 是最强大的开源 AI 创作平台'
}
},
{
id: 'cloud-no-gpu',
description: {
en: 'Comfy Cloud lets you run ComfyUI in the browser, no GPU needed, all models pre-loaded',
'zh-CN':
'Comfy Cloud 让你在浏览器中运行 ComfyUI无需 GPU所有模型预加载'
}
},
{
id: 'node-based-control',
description: {
en: 'Node-based workflows give users full creative control unlike prompt-only tools',
'zh-CN':
'基于节点的工作流让用户拥有完整的创作控制力,区别于仅靠提示词的工具'
}
},
{
id: 'custom-nodes',
description: {
en: '1,000+ community custom node packages',
'zh-CN': '1,000+ 社区自定义节点包'
}
}
] as const

View File

@@ -0,0 +1,38 @@
import type { LocalizedText } from '../i18n/translations'
interface AffiliateBrandAsset {
id: string
title: LocalizedText
download: string
preview: string
}
const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
{
id: 'core-logo',
title: { en: 'Core Logo', 'zh-CN': '核心标志' },
download: BRAND_ASSETS_ZIP,
preview: '/icons/logo.svg'
},
{
id: 'logomark',
title: { en: 'Logomark', 'zh-CN': '标志符号' },
download: BRAND_ASSETS_ZIP,
preview: '/icons/logomark.svg'
},
{
id: 'icon',
title: { en: 'Icon', 'zh-CN': '图标' },
download: BRAND_ASSETS_ZIP,
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
},
{
id: 'amplified-logomark',
title: { en: 'Amplified Logomark', 'zh-CN': '放大版标志符号' },
download: BRAND_ASSETS_ZIP,
preview: '/affiliates/brand/comfy-amplified-logo.png'
}
] as const

View File

@@ -0,0 +1,103 @@
import type { LocalizedText } from '../i18n/translations'
interface AffiliateFaq {
id: string
question: LocalizedText
answer: LocalizedText
}
export const affiliateFaqs: readonly AffiliateFaq[] = [
{
id: 'how-do-i-track-my-referrals',
question: {
en: 'How do I track my referrals?',
'zh-CN': '我如何追踪我的推荐?'
},
answer: {
en: 'Real-time dashboard via our partner portal.',
'zh-CN': '通过我们的合作伙伴门户使用实时仪表盘追踪。'
}
},
{
id: 'what-plans-qualify',
question: {
en: 'What plans qualify?',
'zh-CN': '哪些订阅方案符合条件?'
},
answer: {
en: 'All Comfy Cloud paid subscription plans (Standard, Creator, Pro, Teams).',
'zh-CN':
'所有 Comfy Cloud 付费订阅方案Standard、Creator、Pro、Teams。'
}
},
{
id: 'how-long-to-get-approved',
question: {
en: 'How long does approval take?',
'zh-CN': '审核需要多长时间?'
},
answer: {
en: 'Most applications approved within 24 hours.',
'zh-CN': '大多数申请会在 24 小时内获批。'
}
},
{
id: 'when-do-i-get-paid',
question: {
en: 'When do I get paid?',
'zh-CN': '什么时候结算佣金?'
},
answer: {
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
'zh-CN':
'每月结算,于每月前 10 个工作日内发放。最低结算余额为 100 美元,通过 Stripe Express 或 PayPal 支付。'
}
},
{
id: 'what-happens-if-referral-upgrades-or-downgrades',
question: {
en: 'What happens if my referral upgrades or downgrades?',
'zh-CN': '如果我推荐的用户升级或降级订阅会怎样?'
},
answer: {
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
'zh-CN':
'如果他们升级订阅,您的佣金会相应增加;如果降级,佣金也会同步调整。佣金以 Comfy.org 实际收到的金额为准,并扣除退款部分。'
}
},
{
id: 'can-i-use-coupon-codes',
question: {
en: 'Can I use coupon codes?',
'zh-CN': '我可以使用优惠码吗?'
},
answer: {
en: 'Yes. We support both tracking links and unique coupon codes.',
'zh-CN': '可以。我们同时支持追踪链接和专属优惠码。'
}
},
{
id: 'what-if-my-referral-uses-an-ad-blocker',
question: {
en: 'What if my referral uses an ad blocker?',
'zh-CN': '如果我推荐的用户使用广告拦截器怎么办?'
},
answer: {
en: 'We use server-side tracking, so conversions are tracked regardless.',
'zh-CN':
'我们采用服务端追踪,因此无论用户是否使用广告拦截器,转化都能正常记录。'
}
},
{
id: 'what-assets-do-you-provide',
question: {
en: 'What assets do you provide?',
'zh-CN': '你们提供哪些素材?'
},
answer: {
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
'zh-CN':
'本页面提供 Logo 和横幅图,获批后您还可以在联盟仪表盘中获取截图和宣传文案。'
}
}
] as const

View File

@@ -0,0 +1,45 @@
import type { LocalizedText } from '../i18n/translations'
interface HowItWorksStep {
id: string
label: LocalizedText
description: LocalizedText
}
export const affiliateHowItWorksSteps: readonly HowItWorksStep[] = [
{
id: 'apply',
label: {
en: 'Apply',
'zh-CN': '申请'
},
description: {
en: 'Submit a quick form. Most applicants approved same day.',
'zh-CN': '填写一份简短表单。大多数申请当天获批。'
}
},
{
id: 'share',
label: {
en: 'Share',
'zh-CN': '分享'
},
description: {
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
'zh-CN':
'获取您的专属追踪链接。通过内容、社交、邮件等任何触达受众的方式分享。'
}
},
{
id: 'earn',
label: {
en: 'Earn',
'zh-CN': '赚取'
},
description: {
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
'zh-CN':
'每位您推荐的 Comfy Cloud 订阅者,可获连续 3 个月 30% 的经常性佣金。实时追踪,每月结算。'
}
}
] as const

View File

@@ -7,6 +7,8 @@ export interface GalleryItem {
teamAlias: string
tool: string
href?: string
objectPosition?: string
objectFit?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}

View File

@@ -1879,8 +1879,9 @@ const translations = {
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
},
'privacy.intro.block.3': {
en: 'This policy is effective as of April 18, 2025.',
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
'zh-CN':
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop本地安装应用程序的具体信息包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
},
'privacy.information-we-collect.label': {
en: 'INFORMATION',
@@ -2130,6 +2131,181 @@ const translations = {
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
},
// ── Desktop Privacy Policy ────────────────────────────────────────
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
'desktop_privacy.intro.block.0': {
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
},
'desktop_privacy.intro.block.1': {
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
'zh-CN':
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
},
'desktop_privacy.controller.label': {
en: 'CONTROLLER',
'zh-CN': 'CONTROLLER'
},
'desktop_privacy.controller.title': {
en: 'Controller',
'zh-CN': 'Controller'
},
'desktop_privacy.controller.block.0': {
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
'desktop_privacy.data.label': {
en: 'DATA WE PROCESS',
'zh-CN': 'DATA WE PROCESS'
},
'desktop_privacy.data.title': {
en: 'Personal data we process',
'zh-CN': 'Personal data we process'
},
'desktop_privacy.data.block.0': {
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
'zh-CN':
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
},
'desktop_privacy.data.block.1': {
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
'zh-CN':
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
},
'desktop_privacy.data.block.2': {
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
'zh-CN':
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
},
'desktop_privacy.data.block.3': {
en: 'We do not process:',
'zh-CN': 'We do not process:'
},
'desktop_privacy.data.block.4': {
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
'zh-CN':
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
},
'desktop_privacy.data.block.5': {
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
'zh-CN':
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
},
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
'desktop_privacy.purposes.title': {
en: 'Purposes and lawful bases',
'zh-CN': 'Purposes and lawful bases'
},
'desktop_privacy.purposes.block.0': {
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
'zh-CN':
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
},
'desktop_privacy.purposes.block.1': {
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
'zh-CN':
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
},
'desktop_privacy.purposes.block.2': {
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
'desktop_privacy.purposes.block.3': {
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
'zh-CN':
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
},
'desktop_privacy.processors.label': {
en: 'RECIPIENTS',
'zh-CN': 'RECIPIENTS'
},
'desktop_privacy.processors.title': {
en: 'Recipients',
'zh-CN': 'Recipients'
},
'desktop_privacy.processors.block.0': {
en: 'We engage the following processors under Data Processing Agreements:',
'zh-CN':
'We engage the following processors under Data Processing Agreements:'
},
'desktop_privacy.processors.block.1': {
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
'zh-CN':
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
},
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
'desktop_privacy.transfers.title': {
en: 'International transfers',
'zh-CN': 'International transfers'
},
'desktop_privacy.transfers.block.0': {
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
'zh-CN':
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
},
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
'desktop_privacy.retention.block.0': {
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
'zh-CN':
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
},
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
'desktop_privacy.rights.block.0': {
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
'zh-CN':
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
},
'desktop_privacy.rights.block.1': {
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
'zh-CN':
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
},
'desktop_privacy.rights.block.2': {
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
'zh-CN':
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
},
'desktop_privacy.rights.block.3': {
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
'zh-CN':
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
},
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
'desktop_privacy.children.block.0': {
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
'zh-CN':
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
},
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
'desktop_privacy.changes.block.0': {
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
'zh-CN':
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
},
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
'desktop_privacy.contact.block.0': {
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
'zh-CN':
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
},
// ── Terms of Service ──────────────────────────────────────────────
'tos.effectiveDateLabel': {
en: 'Effective Date',
@@ -4458,6 +4634,80 @@ const translations = {
'zh-CN': '支持的模型'
},
// Models list page (/models)
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
'models.list.heroCta': {
en: 'Try {name} Now',
'zh-CN': '立即试用 {name}'
},
'models.list.creations.title': {
en: '{name} Image and Video Creations',
'zh-CN': '{name} 图像与视频创作'
},
'models.list.creations.cta': {
en: 'Explore Workflows',
'zh-CN': '探索工作流'
},
'models.list.heroTitle.before': {
en: '{name} in',
'zh-CN': ''
},
'models.list.heroTitle.after': {
en: '',
'zh-CN': ' 中的 {name}'
},
'models.list.heroSubtitle': {
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
'zh-CN':
'从开源扩散模型到合作伙伴 API涵盖每一个主流模型并附带可直接运行的社区工作流模板。'
},
'models.list.card.workflows': {
en: '{count} workflows',
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
en: 'Run the worlds\nleading AI models',
'zh-CN': '运行全球领先的\nAI 模型'
},
'models.showcase.subtitle': {
en: 'New models are added as they launch.',
'zh-CN': '新模型发布后会第一时间上线。'
},
'models.showcase.cta': {
en: 'EXPLORE WORKFLOWS',
'zh-CN': '探索工作流'
},
'models.showcase.card.grokImagine': {
en: 'Grok Imagine',
'zh-CN': 'Grok Imagine'
},
'models.showcase.card.nanoBananaPro': {
en: 'Nano Banana Pro',
'zh-CN': 'Nano Banana Pro'
},
'models.showcase.card.ltx23': {
en: 'LTX 2.3',
'zh-CN': 'LTX 2.3'
},
'models.showcase.card.qwenAdvancedEdit': {
en: 'Advanced image\nediting with Qwen',
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
},
'models.showcase.card.wan22TextToVideo': {
en: 'Wan 2.2\ntext to video',
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',
@@ -4501,313 +4751,103 @@ const translations = {
'zh-CN': '查看订阅文档'
},
// Affiliate landing page (/affiliates)
// English-only copy; zh-CN values mirror en until a localized version lands.
'affiliate-landing.page.title': {
en: 'Comfy.org Affiliate Program — Become a Partner',
'zh-CN': 'Comfy.org Affiliate Program — Become a Partner'
// AffiliateHeroSection
'affiliate.hero.label': { en: 'AFFILIATE', 'zh-CN': '联盟' },
'affiliate.hero.headingHighlight': {
en: 'Earn 30%',
'zh-CN': '赚取 30%'
},
'affiliate-landing.page.description': {
'affiliate.hero.headingMuted': {
en: 'recurring commission for 3 months.',
'zh-CN': '持续返佣 3 个月。'
},
'affiliate.hero.feature1': {
en: '30% recurring commission for 3 months',
'zh-CN': '30% 持续佣金,连续 3 个月'
},
'affiliate.hero.feature2': {
en: '60-day cookie window',
'zh-CN': '60 天 Cookie 窗口'
},
'affiliate.hero.feature3': {
en: '$100 minimum payout',
'zh-CN': '$100 起付'
},
'affiliate.hero.feature4': {
en: 'Monthly payouts',
'zh-CN': '每月结算'
},
'affiliate.hero.apply': { en: 'APPLY NOW', 'zh-CN': '立即申请' },
'affiliate.hero.imageAlt': {
en: 'Comfy affiliate program',
'zh-CN': 'Comfy 联盟计划'
},
// AffiliateAudienceSection
'affiliate.audience.heading': {
en: "Who we're looking for",
'zh-CN': '我们在寻找谁'
},
'affiliate.audience.subheading': {
en: 'If you are...',
'zh-CN': '如果您是……'
},
// AffiliateHowItWorksSection
'affiliate.howItWorks.heading': {
en: 'How it works',
'zh-CN': '运作方式'
},
// AffiliateBenefitsSection
'affiliate.benefits.heading': {
en: 'Why ComfyUI for affiliate creators',
'zh-CN': '为什么联盟创作者选择 ComfyUI'
},
// AffiliateBrandAssetsSection
'affiliate.assets.heading': {
en: 'Brand logos for your content',
'zh-CN': '可用于您内容的品牌 Logo'
},
'affiliate.assets.subheading': {
en: 'Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
'zh-CN': '横幅图、截图和宣传文案将在获批后于联盟仪表盘中提供。'
},
'affiliate.assets.downloadLabel': {
en: 'Download zip',
'zh-CN': '下载压缩包'
},
// AffiliateFAQSection
'affiliate.faq.heading': {
en: 'Frequently asked questions',
'zh-CN': '常见问题'
},
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program — Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 — 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
'zh-CN':
'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.'
},
'affiliate-landing.cta.apply': {
en: 'Apply Now',
'zh-CN': 'Apply Now'
},
'affiliate-landing.cta.applyAriaLabel': {
en: 'Apply Now (opens in new tab)',
'zh-CN': 'Apply Now (opens in new tab)'
'为您推荐的每个 Comfy Cloud 订阅赚取 30% 持续佣金,连续 3 个月。立即申请成为 Comfy 合作伙伴。'
},
// Hero
'affiliate-landing.hero.heading': {
en: 'Become a Comfy Partner',
'zh-CN': 'Become a Comfy Partner'
},
'affiliate-landing.hero.subheading': {
en: 'Earn 30% Commission for 3 Months.',
'zh-CN': 'Earn 30% Commission for 3 Months.'
},
'affiliate-landing.hero.body': {
en: 'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:',
'zh-CN':
'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:'
},
'affiliate-landing.hero.highlight.0': {
en: '30% recurring commission for 3 months',
'zh-CN': '30% recurring commission for 3 months'
},
'affiliate-landing.hero.highlight.1': {
en: '60-day cookie window',
'zh-CN': '60-day cookie window'
},
'affiliate-landing.hero.highlight.2': {
en: '$100 minimum payout',
'zh-CN': '$100 minimum payout'
},
'affiliate-landing.hero.highlight.3': {
en: 'Monthly payouts',
'zh-CN': 'Monthly payouts'
},
// Trust band
'affiliate-landing.trust.label': {
en: 'Trusted by 2M+ creators worldwide',
'zh-CN': 'Trusted by 2M+ creators worldwide'
},
// How it works
'affiliate-landing.how.heading': {
en: 'How it works',
'zh-CN': 'How it works'
},
'affiliate-landing.how.step.0.title': {
en: 'Apply.',
'zh-CN': 'Apply.'
},
'affiliate-landing.how.step.0.body': {
en: 'Submit a quick form. Most applicants approved same day.',
'zh-CN': 'Submit a quick form. Most applicants approved same day.'
},
'affiliate-landing.how.step.1.title': {
en: 'Share.',
'zh-CN': 'Share.'
},
'affiliate-landing.how.step.1.body': {
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
'zh-CN':
'Get your unique tracking link. Share via content, social, email, however you reach your audience.'
},
'affiliate-landing.how.step.2.title': {
en: 'Earn.',
'zh-CN': 'Earn.'
},
'affiliate-landing.how.step.2.body': {
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
'zh-CN':
'30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.'
},
// Who we're looking for
'affiliate-landing.audience.heading': {
en: "Who we're looking for",
'zh-CN': "Who we're looking for"
},
'affiliate-landing.audience.item.0': {
en: 'ComfyUI tutorial creators and workflow builders',
'zh-CN': 'ComfyUI tutorial creators and workflow builders'
},
'affiliate-landing.audience.item.1': {
en: 'AI tool reviewers on YouTube, TikTok, blogs',
'zh-CN': 'AI tool reviewers on YouTube, TikTok, blogs'
},
'affiliate-landing.audience.item.2': {
en: 'Tech bloggers covering AI creative tools',
'zh-CN': 'Tech bloggers covering AI creative tools'
},
'affiliate-landing.audience.item.3': {
en: 'Newsletter operators in the AI/creative space',
'zh-CN': 'Newsletter operators in the AI/creative space'
},
'affiliate-landing.audience.item.4': {
en: 'Anyone with an audience interested in AI image, video, or 3D generation',
'zh-CN':
'Anyone with an audience interested in AI image, video, or 3D generation'
},
// Program details
'affiliate-landing.details.heading': {
en: 'Program details',
'zh-CN': 'Program details'
},
'affiliate-landing.details.headerLabel': {
en: 'Detail',
'zh-CN': 'Detail'
},
'affiliate-landing.details.headerValue': {
en: 'Value',
'zh-CN': 'Value'
},
'affiliate-landing.details.row.0.label': {
en: 'Commission rate',
'zh-CN': 'Commission rate'
},
'affiliate-landing.details.row.0.value': {
en: '30% recurring',
'zh-CN': '30% recurring'
},
'affiliate-landing.details.row.1.label': {
en: 'Commission duration',
'zh-CN': 'Commission duration'
},
'affiliate-landing.details.row.1.value': {
en: '3 months',
'zh-CN': '3 months'
},
'affiliate-landing.details.row.2.label': {
en: 'Cookie window',
'zh-CN': 'Cookie window'
},
'affiliate-landing.details.row.2.value': {
en: '60 days',
'zh-CN': '60 days'
},
'affiliate-landing.details.row.3.label': {
en: 'Eligible products',
'zh-CN': 'Eligible products'
},
'affiliate-landing.details.row.3.value': {
en: 'Comfy Cloud paid subscription plans',
'zh-CN': 'Comfy Cloud paid subscription plans'
},
'affiliate-landing.details.row.4.label': {
en: 'Payouts',
'zh-CN': 'Payouts'
},
'affiliate-landing.details.row.4.value': {
en: 'Monthly, within first 10 business days',
'zh-CN': 'Monthly, within first 10 business days'
},
'affiliate-landing.details.row.5.label': {
en: 'Minimum payout',
'zh-CN': 'Minimum payout'
},
'affiliate-landing.details.row.5.value': {
en: '$100',
'zh-CN': '$100'
},
// Brand assets
'affiliate-landing.assets.heading': {
en: 'Brand assets',
'zh-CN': 'Brand assets'
},
'affiliate-landing.assets.subheading': {
en: 'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
'zh-CN':
'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.'
},
'affiliate-landing.assets.downloadLabel': {
en: 'Download',
'zh-CN': 'Download'
},
'affiliate-landing.assets.tile.logo-horizontal.title': {
en: 'Comfy logo (horizontal)',
'zh-CN': 'Comfy logo (horizontal)'
},
'affiliate-landing.assets.tile.logomark.title': {
en: 'Comfy logomark',
'zh-CN': 'Comfy logomark'
},
'affiliate-landing.assets.tile.comfy-full-logo-yellow.title': {
en: 'Comfy full logo (yellow)',
'zh-CN': 'Comfy full logo (yellow)'
},
'affiliate-landing.assets.tile.comfy-full-logo-ink.title': {
en: 'Comfy full logo (ink)',
'zh-CN': 'Comfy full logo (ink)'
},
'affiliate-landing.assets.tile.amplified-logo-mark.title': {
en: 'Amplified logo mark',
'zh-CN': 'Amplified logo mark'
},
'affiliate-landing.assets.tile.dimensional-logo-mark.title': {
en: 'Dimensional logo mark',
'zh-CN': 'Dimensional logo mark'
},
'affiliate-landing.assets.tile.color-combo-yellow.title': {
en: 'Color combo (yellow)',
'zh-CN': 'Color combo (yellow)'
},
'affiliate-landing.assets.tile.color-combo-ink.title': {
en: 'Color combo (ink)',
'zh-CN': 'Color combo (ink)'
},
// FAQ — keys follow the FAQSection contract: <prefix>.<n>.q / <prefix>.<n>.a, 1-indexed
'affiliate-landing.faq.heading': {
en: 'Questions',
'zh-CN': 'Questions'
},
'affiliate-landing.faq.1.q': {
en: 'How do I track my referrals?',
'zh-CN': 'How do I track my referrals?'
},
'affiliate-landing.faq.1.a': {
en: 'Real-time dashboard via our partner portal.',
'zh-CN': 'Real-time dashboard via our partner portal.'
},
'affiliate-landing.faq.2.q': {
en: 'What plans qualify?',
'zh-CN': 'What plans qualify?'
},
'affiliate-landing.faq.2.a': {
en: 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).',
'zh-CN': 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).'
},
'affiliate-landing.faq.3.q': {
en: 'How long does approval take?',
'zh-CN': 'How long does approval take?'
},
'affiliate-landing.faq.3.a': {
en: 'Most applications approved within 24 hours.',
'zh-CN': 'Most applications approved within 24 hours.'
},
'affiliate-landing.faq.4.q': {
en: 'When do I get paid?',
'zh-CN': 'When do I get paid?'
},
'affiliate-landing.faq.4.a': {
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
'zh-CN':
'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.'
},
'affiliate-landing.faq.5.q': {
en: 'What happens if my referral upgrades or downgrades?',
'zh-CN': 'What happens if my referral upgrades or downgrades?'
},
'affiliate-landing.faq.5.a': {
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
'zh-CN':
'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.'
},
'affiliate-landing.faq.6.q': {
en: 'Can I use coupon codes?',
'zh-CN': 'Can I use coupon codes?'
},
'affiliate-landing.faq.6.a': {
en: 'Yes. We support both tracking links and unique coupon codes.',
'zh-CN': 'Yes. We support both tracking links and unique coupon codes.'
},
'affiliate-landing.faq.7.q': {
en: 'What if my referral uses an ad blocker?',
'zh-CN': 'What if my referral uses an ad blocker?'
},
'affiliate-landing.faq.7.a': {
en: 'We use server-side tracking, so conversions are tracked regardless.',
'zh-CN':
'We use server-side tracking, so conversions are tracked regardless.'
},
'affiliate-landing.faq.8.q': {
en: 'What assets do you provide?',
'zh-CN': 'What assets do you provide?'
},
'affiliate-landing.faq.8.a': {
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
'zh-CN':
'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.'
},
// Footer CTA
'affiliate-landing.footerCta.heading': {
// AffiliateCtaSection
'affiliate.cta.heading': {
en: 'Ready to start earning?',
'zh-CN': 'Ready to start earning?'
'zh-CN': '准备好开始赚取佣金了吗?'
},
'affiliate-landing.footerCta.termsLink': {
'affiliate.cta.apply': {
en: 'APPLY NOW',
'zh-CN': '立即申请'
},
'affiliate.cta.termsLabel': {
en: 'Read the affiliate program terms',
'zh-CN': 'Read the affiliate program terms'
'zh-CN': '阅读联盟计划条款'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -1,44 +1,34 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import AudienceSection from '../../components/affiliates/AudienceSection.vue'
import BrandAssetsSection from '../../components/affiliates/BrandAssetsSection.vue'
import FooterCtaSection from '../../components/affiliates/FooterCtaSection.vue'
import HeroSection from '../../components/affiliates/HeroSection.vue'
import HowItWorksSection from '../../components/affiliates/HowItWorksSection.vue'
import ProgramDetailsSection from '../../components/affiliates/ProgramDetailsSection.vue'
import TrustBandSection from '../../components/affiliates/TrustBandSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import {
AFFILIATE_FAQ_COUNT,
AFFILIATE_FAQ_HEADING_KEY,
AFFILIATE_FAQ_PREFIX
} from '../../components/affiliates/affiliateFaqs'
import type { Locale, TranslationKey } from '../../i18n/translations'
import AudienceSection from '../../templates/affiliate/AudienceSection.vue'
import BenefitsSection from '../../templates/affiliate/BenefitsSection.vue'
import BrandAssetsSection from '../../templates/affiliate/BrandAssetsSection.vue'
import CtaSection from '../../templates/affiliate/CtaSection.vue'
import FAQSection from '../../templates/affiliate/FAQSection.vue'
import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
const locale: Locale =
Astro.currentLocale === 'zh-CN' ? 'zh-CN' : 'en'
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => {
const n = i + 1
return {
'@type': 'Question',
name: t(`${AFFILIATE_FAQ_PREFIX}.${n}.q` as TranslationKey, locale),
acceptedAnswer: {
'@type': 'Answer',
text: t(`${AFFILIATE_FAQ_PREFIX}.${n}.a` as TranslationKey, locale)
}
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
})
}))
}
---
<BaseLayout
title={t('affiliate-landing.page.title', locale)}
description={t('affiliate-landing.page.description', locale)}
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
>
<Fragment slot="head">
<script
@@ -48,18 +38,11 @@ const faqJsonLd = {
/>
</Fragment>
<HeroSection locale={locale} client:load />
<TrustBandSection locale={locale} />
<HowItWorksSection locale={locale} />
<AudienceSection locale={locale} />
<ProgramDetailsSection locale={locale} />
<BrandAssetsSection locale={locale} />
<FAQSection
locale={locale}
headingKey={AFFILIATE_FAQ_HEADING_KEY}
faqPrefix={AFFILIATE_FAQ_PREFIX}
faqCount={AFFILIATE_FAQ_COUNT}
client:load
/>
<FooterCtaSection locale={locale} client:load />
<HeroSection />
<HowItWorksSection />
<AudienceSection />
<BenefitsSection />
<BrandAssetsSection />
<FAQSection client:visible />
<CtaSection />
</BaseLayout>

View File

@@ -0,0 +1,22 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="Models — Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection
modelName="Grok Imagine"
ctaHref="/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -0,0 +1,13 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ContentSection from '../../components/common/ContentSection.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy — Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />
<ContentSection prefix="desktop_privacy" client:load />
</BaseLayout>

View File

@@ -0,0 +1,23 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="模型 — Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection
locale="zh-CN"
modelName="Grok Imagine"
ctaHref="/zh-CN/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,13 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ContentSection from '../../../components/common/ContentSection.vue'
import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 — Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
</BaseLayout>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import ChecklistSplit01 from '../../components/blocks/ChecklistSplit01.vue'
import { affiliateAudienceCriteria } from '../../data/affiliateAudience'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const criteria = affiliateAudienceCriteria.map((criterion) => ({
id: criterion.id,
label: criterion.label[locale]
}))
</script>
<template>
<ChecklistSplit01
:heading="t('affiliate.audience.heading', locale)"
:subheading="t('affiliate.audience.subheading', locale)"
:criteria="criteria"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import BenefitsGrid01 from '../../components/blocks/BenefitsGrid01.vue'
import { externalLinks } from '../../config/routes'
import { affiliateBenefits } from '../../data/affiliateBenefits'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const benefits = affiliateBenefits.map((benefit) => ({
id: benefit.id,
description: benefit.description[locale]
}))
</script>
<template>
<BenefitsGrid01
:heading="t('affiliate.benefits.heading', locale)"
:benefits="benefits"
:primary-cta="{
label: t('affiliate.hero.apply', locale),
href: externalLinks.affiliateApplicationForm,
target: '_blank'
}"
/>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import BrandAssetsGrid01 from '../../components/blocks/BrandAssetsGrid01.vue'
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const assets = affiliateBrandAssets.map((asset) => ({
id: asset.id,
title: asset.title[locale],
download: asset.download,
preview: asset.preview
}))
</script>
<template>
<BrandAssetsGrid01
:heading="t('affiliate.assets.heading', locale)"
:subheading="t('affiliate.assets.subheading', locale)"
:download-label="t('affiliate.assets.downloadLabel', locale)"
:assets="assets"
/>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<CtaCenter01
:heading="t('affiliate.cta.heading', locale)"
:primary-cta="{
label: t('affiliate.cta.apply', locale),
href: externalLinks.affiliateApplicationForm,
target: '_blank'
}"
:terms-link="{
label: t('affiliate.cta.termsLabel', locale),
href: routes.affiliateTerms
}"
/>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqs = affiliateFaqs.map((faq) => ({
id: faq.id,
question: faq.question[locale],
answer: faq.answer[locale]
}))
</script>
<template>
<FAQSplit01 :heading="t('affiliate.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import { externalLinks } from '@/config/routes.ts'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<HeroSplit01
:badge-text="t('affiliate.hero.label', locale)"
:title-highlight="t('affiliate.hero.headingHighlight', locale)"
:title="t('affiliate.hero.headingMuted', locale)"
:features="[
t('affiliate.hero.feature1', locale),
t('affiliate.hero.feature2', locale),
t('affiliate.hero.feature3', locale),
t('affiliate.hero.feature4', locale)
]"
:primary-cta="{
label: t('affiliate.hero.apply', locale),
href: externalLinks.affiliateApplicationForm
}"
video-autoplay
video-loop
video-hide-controls
video-src="https://media.comfy.org/website/affiliates/rainlit-ronin_compressed.mp4"
:image-alt="t('affiliate.hero.imageAlt', locale)"
/>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import StepsGrid01 from '../../components/blocks/StepsGrid01.vue'
import { affiliateHowItWorksSteps } from '../../data/affiliateHowItWorks'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps = affiliateHowItWorksSteps.map((step) => ({
id: step.id,
label: step.label[locale],
description: step.description[locale]
}))
</script>
<template>
<StepsGrid01
:heading="t('affiliate.howItWorks.heading', locale)"
:steps="steps"
/>
</template>

View File

@@ -69,11 +69,6 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

View File

@@ -545,4 +545,54 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
await expect(expansionContent).toBeHidden()
})
})
test.describe('Responsive Layout', () => {
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
await expect(row).toBeVisible()
await page.setViewportSize({ width: 480, height: 800 })
await expect(
row.getByRole('button', { name: /Delete/i })
).toBeInViewport()
await expect(
row.getByRole('button', { name: /Add new keybinding/i })
).toBeInViewport()
const hasHorizontalScroll = await page
.locator('.keybinding-panel .p-datatable-table-container')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
expect(hasHorizontalScroll).toBe(false)
})
test('Keybinding column compresses with width while actions stay reachable', async ({
comfyPage
}) => {
const { page } = comfyPage
await searchKeybindings(page, MULTI_BINDING_COMMAND)
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
const keybindingList = row.getByTestId('keybinding-list')
await expect(keybindingList).toBeVisible()
const listWidthAt = async (viewportWidth: number) => {
await page.setViewportSize({ width: viewportWidth, height: 800 })
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
}
const wideWidth = await listWidthAt(1280)
const narrowWidth = await listWidthAt(560)
expect(narrowWidth).toBeLessThan(wideWidth)
await expect(
row.getByRole('button', { name: /Delete/i })
).toBeInViewport()
})
})
})

View File

@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
test('Shows missing item label and locate action', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
).toBeVisible()
})
})
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
await getMediaRow(comfyPage)
.getByRole('button', { name: 'Load Image - image', exact: true })
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.10",
"version": "1.46.11",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -28,13 +28,15 @@ export type {
BillingPlansResponse,
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
BulkRevokeApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysData,
BulkRevokeWorkspaceMemberApiKeysError,
BulkRevokeWorkspaceMemberApiKeysErrors,
BulkRevokeWorkspaceMemberApiKeysResponse,
BulkRevokeWorkspaceMemberApiKeysResponses,
CancelAssetSeedData,
CancelAssetSeedResponse,
CancelAssetSeedResponses,
CancelJobData,
CancelJobError,
CancelJobErrors,
@@ -57,11 +59,14 @@ export type {
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClientOptions,
CreateAssetData,
CreateAssetDownloadData,
CreateAssetDownloadError,
CreateAssetDownloadErrors,
CreateAssetDownloadResponse,
CreateAssetDownloadResponses,
CreateAssetError,
CreateAssetErrors,
CreateAssetExportData,
CreateAssetExportError,
CreateAssetExportErrors,
@@ -72,6 +77,8 @@ export type {
CreateAssetFromHashErrors,
CreateAssetFromHashResponse,
CreateAssetFromHashResponses,
CreateAssetResponse,
CreateAssetResponses,
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
@@ -208,6 +215,8 @@ export type {
ForkWorkflowRequest,
ForkWorkflowResponse,
ForkWorkflowResponses,
FreeMemoryData,
FreeMemoryResponses,
GetAllSettingsData,
GetAllSettingsError,
GetAllSettingsErrors,
@@ -221,6 +230,9 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
GetAssetTagHistogramData,
GetAssetTagHistogramError,
GetAssetTagHistogramErrors,
@@ -259,6 +271,9 @@ export type {
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetEmbeddingsData,
GetEmbeddingsResponse,
GetEmbeddingsResponses,
GetExtensionsData,
GetExtensionsResponse,
GetExtensionsResponses,
@@ -305,6 +320,18 @@ export type {
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetI18nData,
GetI18nResponse,
GetI18nResponses,
GetInternalFolderPathsData,
GetInternalFolderPathsResponse,
GetInternalFolderPathsResponses,
GetInternalLogsData,
GetInternalLogsRawData,
GetInternalLogsRawResponse,
GetInternalLogsRawResponses,
GetInternalLogsResponse,
GetInternalLogsResponses,
GetJobDetailData,
GetJobDetailError,
GetJobDetailErrors,
@@ -356,10 +383,7 @@ export type {
GetModelFoldersResponse,
GetModelFoldersResponses,
GetModelPreviewData,
GetModelPreviewError,
GetModelPreviewErrors,
GetModelPreviewResponse,
GetModelPreviewResponses,
GetModelsInFolderData,
GetModelsInFolderError,
GetModelsInFolderErrors,
@@ -389,8 +413,26 @@ export type {
GetNodeReplacementsErrors,
GetNodeReplacementsResponse,
GetNodeReplacementsResponses,
GetOpenapiSpecData,
GetOpenapiSpecResponses,
GetOAuthAuthorizationServerData,
GetOAuthAuthorizationServerError,
GetOAuthAuthorizationServerErrors,
GetOAuthAuthorizationServerResponse,
GetOAuthAuthorizationServerResponses,
GetOAuthAuthorizeData,
GetOAuthAuthorizeError,
GetOAuthAuthorizeErrors,
GetOAuthAuthorizeResponse,
GetOAuthAuthorizeResponses,
GetOAuthProtectedResourceByPathData,
GetOAuthProtectedResourceByPathError,
GetOAuthProtectedResourceByPathErrors,
GetOAuthProtectedResourceByPathResponse,
GetOAuthProtectedResourceByPathResponses,
GetOAuthProtectedResourceData,
GetOAuthProtectedResourceError,
GetOAuthProtectedResourceErrors,
GetOAuthProtectedResourceResponse,
GetOAuthProtectedResourceResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
@@ -427,11 +469,11 @@ export type {
GetSecretErrors,
GetSecretResponse,
GetSecretResponses,
GetSettingByKeyData,
GetSettingByKeyError,
GetSettingByKeyErrors,
GetSettingByKeyResponse,
GetSettingByKeyResponses,
GetSettingByIdData,
GetSettingByIdError,
GetSettingByIdErrors,
GetSettingByIdResponse,
GetSettingByIdResponses,
GetStaticExtensionsData,
GetStaticExtensionsErrors,
GetStaticExtensionsResponses,
@@ -447,6 +489,7 @@ export type {
GetTaskResponses,
GetTemplateProxyData,
GetTemplateProxyErrors,
GetTemplateProxyResponses,
GetUserData,
GetUserdataData,
GetUserdataError,
@@ -534,6 +577,11 @@ export type {
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InsertDynamicConfigData,
InsertDynamicConfigError,
InsertDynamicConfigErrors,
InsertDynamicConfigResponse,
InsertDynamicConfigResponses,
InterruptJobData,
InterruptJobError,
InterruptJobErrors,
@@ -642,6 +690,17 @@ export type {
MoveUserdataFileResponse,
MoveUserdataFileResponses,
NodeInfo,
OAuthAuthorizationServerMetadata,
OAuthAuthorizeRedirectResponse,
OAuthConsentChallenge,
OAuthConsentChallengeWorkspace,
OAuthProtectedResourceMetadata,
OAuthRegisterBadRequestResponse,
OAuthRegisterError,
OAuthRegisterRequest,
OAuthRegisterResponse,
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
@@ -663,6 +722,21 @@ export type {
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
PostOAuthAuthorizeResponse,
PostOAuthAuthorizeResponses,
PostOAuthRegisterData,
PostOAuthRegisterError,
PostOAuthRegisterErrors,
PostOAuthRegisterResponse,
PostOAuthRegisterResponses,
PostOAuthTokenData,
PostOAuthTokenError,
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
@@ -687,6 +761,9 @@ export type {
PromptInfo,
PromptRequest,
PromptResponse,
PruneAssetsData,
PruneAssetsResponse,
PruneAssetsResponses,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
@@ -732,6 +809,9 @@ export type {
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
SeedAssetsResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
@@ -751,6 +831,8 @@ export type {
SubscribeResponse,
SubscribeResponse2,
SubscribeResponses,
SubscribeToLogsData,
SubscribeToLogsResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
@@ -771,11 +853,6 @@ export type {
UpdateAssetErrors,
UpdateAssetResponse,
UpdateAssetResponses,
UpdateAssetTagsData,
UpdateAssetTagsError,
UpdateAssetTagsErrors,
UpdateAssetTagsResponse,
UpdateAssetTagsResponses,
UpdateHubProfileData,
UpdateHubProfileError,
UpdateHubProfileErrors,
@@ -799,11 +876,11 @@ export type {
UpdateSecretRequest,
UpdateSecretResponse,
UpdateSecretResponses,
UpdateSettingByKeyData,
UpdateSettingByKeyError,
UpdateSettingByKeyErrors,
UpdateSettingByKeyResponse,
UpdateSettingByKeyResponses,
UpdateSettingByIdData,
UpdateSettingByIdError,
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
@@ -821,11 +898,6 @@ export type {
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,
UploadAssetData,
UploadAssetError,
UploadAssetErrors,
UploadAssetResponse,
UploadAssetResponses,
UploadImageData,
UploadImageError,
UploadImageErrors,

File diff suppressed because it is too large Load Diff

View File

@@ -399,13 +399,18 @@ export const zCreateWorkflowVersionRequest = z.object({
})
/**
* Offset/limit-based pagination metadata included in list responses.
* Pagination metadata included in list responses. Supports both legacy
* offset/limit pagination and cursor-based pagination. When cursor-based
* pagination is used, `next_cursor` is the primary pagination token and
* `offset`/`total` may be zero.
*
*/
export const zPaginationInfo = z.object({
offset: z.number().int().gte(0),
limit: z.number().int().gte(1),
total: z.number().int().gte(0),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -879,6 +884,155 @@ export const zJwkKey = z.object({
y: z.string()
})
/**
* RFC 6749 §5.2 error response.
*/
export const zOAuthTokenError = z.object({
error: z.string(),
error_description: z.string().optional()
})
/**
* RFC 6749 §5.1 successful token response.
*/
export const zOAuthTokenResponse = z.object({
access_token: z.string(),
token_type: z.enum(['Bearer']),
expires_in: z.number().int(),
refresh_token: z.string(),
scope: z.string()
})
/**
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
*
*/
export const zOAuthConsentChallengeWorkspace = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team']),
role: z.enum(['owner', 'member'])
})
/**
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
*/
export const zOAuthAuthorizeRedirectResponse = z.object({
redirect_url: z.string().url()
})
/**
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
*
*/
export const zOAuthConsentChallenge = z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
/**
* OAuth 2.1 protected-resource metadata (RFC 9728).
*/
export const zOAuthProtectedResourceMetadata = z.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()),
scopes_supported: z.array(z.string()),
bearer_methods_supported: z.array(z.string()).optional()
})
/**
* RFC 7591 §3.2.2 error response.
*/
export const zOAuthRegisterError = z.object({
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
error_description: z.string().nullish()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional()
})
/**
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
*
*/
export const zOAuthRegisterBadRequestResponse = z.union([
zOAuthRegisterError,
zErrorResponse
])
/**
* RFC 7591 §3.2.1 successful registration response.
*/
export const zOAuthRegisterResponse = z.object({
client_id: z.string(),
client_id_issued_at: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
client_name: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.enum(['none']),
application_type: z.enum(['native', 'web'])
})
/**
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
*
*/
export const zOAuthRegisterRequest = z.object({
redirect_uris: z.array(z.string()).min(1).max(5),
client_name: z.string().max(100).optional(),
application_type: z.enum(['native', 'web']).optional(),
token_endpoint_auth_method: z.enum(['none']).optional(),
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token']))
.optional(),
response_types: z.array(z.enum(['code'])).optional(),
scope: z.string().nullish(),
resource_grants: z.record(z.array(z.string())).nullish(),
client_uri: z.string().nullish(),
logo_uri: z.string().nullish(),
tos_uri: z.string().nullish(),
policy_uri: z.string().nullish(),
software_id: z.string().nullish(),
software_version: z.string().nullish(),
contacts: z.array(z.string()).nullish(),
jwks: z.record(z.unknown()).nullish(),
jwks_uri: z.string().nullish()
})
/**
* OAuth 2.1 authorization-server metadata (RFC 8414).
*/
export const zOAuthAuthorizationServerMetadata = z.object({
issuer: z.string().url(),
authorization_endpoint: z.string().url(),
token_endpoint: z.string().url(),
jwks_uri: z.string().url(),
registration_endpoint: z.string().url().optional(),
response_types_supported: z.array(z.string()),
grant_types_supported: z.array(z.string()),
code_challenge_methods_supported: z.array(z.string()),
token_endpoint_auth_methods_supported: z.array(z.string()),
scopes_supported: z.array(z.string()).optional()
})
/**
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
*/
@@ -940,6 +1094,7 @@ export const zWorkspaceApiKeyInfo = z.object({
workspace_id: z.string(),
user_id: z.string(),
name: z.string(),
description: z.string().max(5000),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
last_used_at: z.string().datetime().optional(),
@@ -960,6 +1115,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().max(5000),
key: z.string(),
key_prefix: z.string(),
expires_at: z.string().datetime().optional(),
@@ -971,6 +1127,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
*/
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
description: z.string().max(5000).optional(),
expires_at: z.string().datetime().optional()
})
@@ -1353,7 +1510,8 @@ export const zListTagsResponse = z.object({
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1364,19 +1522,20 @@ export const zAsset = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
is_immutable: z.boolean().optional(),
file_path: z.string().nullish()
})
/**
@@ -1385,7 +1544,8 @@ export const zAsset = z.object({
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1394,14 +1554,17 @@ export const zListAssetsResponse = z.object({
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
tags: z.array(z.string()).optional(),
mime_type: z.string().optional(),
user_metadata: z.record(z.unknown()).optional(),
updated_at: z.string().datetime()
job_id: z.string().uuid().nullish(),
updated_at: z.string().datetime(),
file_path: z.string().nullish()
})
/**
@@ -1753,21 +1916,6 @@ export const zExportDownloadUrlResponse = z.object({
expires_at: z.string().datetime().optional()
})
/**
* Error shape returned when request binding or validation fails before the handler runs.
*/
export const zBindingErrorResponse = z.object({
message: z.string()
})
/**
* Standard error response with a machine-readable code and human-readable message.
*/
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
})
/**
* Response returned after successfully queuing a workflow prompt.
*/
@@ -1796,7 +1944,8 @@ export const zPromptRequest = z.object({
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
asset_hash: z
display_name: z.string().nullish(),
hash: z
.string()
.regex(/^blake3:[a-f0-9]{64}$/)
.optional(),
@@ -1807,18 +1956,19 @@ export const zAssetWritable = z.object({
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
})
.optional(),
mime_type: z.string().optional(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
is_immutable: z.boolean().optional(),
file_path: z.string().nullish()
})
/**
@@ -1827,7 +1977,8 @@ export const zAssetWritable = z.object({
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean()
has_more: z.boolean(),
next_cursor: z.string().optional()
})
/**
@@ -1961,21 +2112,6 @@ export const zGetModelsInFolderData = z.object({
*/
export const zGetModelsInFolderResponse = z.array(zModelFile)
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
/**
* Success - Model preview image
*/
export const zGetModelPreviewResponse = z.string()
export const zGetLegacyHistoryData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2027,6 +2163,7 @@ export const zListJobsData = z.object({
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
sort_by: z.enum(['create_time', 'execution_time']).optional(),
sort_order: z.enum(['asc', 'desc']).optional(),
after: z.string().optional(),
offset: z.number().int().gte(0).optional().default(0),
limit: z.number().int().gte(1).lte(1000).optional().default(100)
})
@@ -2132,9 +2269,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional()
hash: z.string().optional(),
after: z.string().optional()
})
.optional()
})
@@ -2144,26 +2281,34 @@ export const zListAssetsData = z.object({
*/
export const zListAssetsResponse2 = zListAssetsResponse
export const zUploadAssetData = z.object({
export const zCreateAssetData = z.object({
body: z.object({
url: z.string().url(),
name: z.string(),
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_id: z.string().uuid().optional()
file: z.string(),
hash: z
.string()
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
.optional(),
tags: z.string().optional(),
id: z.string().uuid().optional(),
preview_id: z.string().uuid().optional(),
name: z.string().optional(),
mime_type: z.string().optional(),
user_metadata: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Asset already exists (returned existing asset)
* Asset already existed for this user (deduplicated by content hash); the
* existing asset is returned with created_new=false.
*
*/
export const zUploadAssetResponse = zAssetCreated
export const zCreateAssetResponse = zAssetCreated
export const zCreateAssetFromHashData = z.object({
body: z.object({
hash: z.string().regex(/^(blake3|sha256):[a-f0-9]{64}$/),
hash: z.string().regex(/^blake3:[a-f0-9]{64}$/),
name: z.string().optional(),
tags: z.array(z.string()).min(1),
mime_type: z.string().optional(),
@@ -2174,7 +2319,9 @@ export const zCreateAssetFromHashData = z.object({
})
/**
* Asset reference already exists (returned existing)
* Asset reference already existed for this user (deduplicated by content
* hash); the existing asset is returned with created_new=false.
*
*/
export const zCreateAssetFromHashResponse = zAssetCreated
@@ -2214,7 +2361,8 @@ export const zCreateAssetExportData = z.object({
naming_strategy: z
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
include_previews: z.boolean().optional().default(false)
}),
path: z.never().optional(),
query: z.never().optional()
@@ -2247,7 +2395,7 @@ export const zDeleteAssetData = z.object({
})
/**
* Asset deleted successfully
* Asset record deleted successfully
*/
export const zDeleteAssetResponse = z.void()
@@ -2312,22 +2460,6 @@ export const zAddAssetTagsData = z.object({
*/
export const zAddAssetTagsResponse = zTagsModificationResponse
export const zUpdateAssetTagsData = z.object({
body: z.object({
add: z.array(z.string()).optional(),
remove: z.array(z.string()).optional()
}),
path: z.object({
id: z.string().uuid()
}),
query: z.never().optional()
})
/**
* Tags updated successfully
*/
export const zUpdateAssetTagsResponse = zTagsModificationResponse
export const zListTagsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2509,10 +2641,10 @@ export const zUpdateMultipleSettingsData = z.object({
*/
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
export const zGetSettingByKeyData = z.object({
export const zGetSettingByIdData = z.object({
body: z.never().optional(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2520,14 +2652,14 @@ export const zGetSettingByKeyData = z.object({
/**
* Setting value response
*/
export const zGetSettingByKeyResponse = z.object({
export const zGetSettingByIdResponse = z.object({
value: z.unknown().optional()
})
export const zUpdateSettingByKeyData = z.object({
export const zUpdateSettingByIdData = z.object({
body: z.unknown(),
path: z.object({
key: z.string()
id: z.string()
}),
query: z.never().optional()
})
@@ -2535,7 +2667,7 @@ export const zUpdateSettingByKeyData = z.object({
/**
* Updated setting value response
*/
export const zUpdateSettingByKeyResponse = z.object({
export const zUpdateSettingByIdResponse = z.object({
value: z.unknown().optional()
})
@@ -2691,21 +2823,7 @@ export const zUploadMaskData = z.object({
export const zUploadMaskResponse = z.object({
name: z.string().optional(),
subfolder: z.string().optional(),
type: z.string().optional(),
metadata: z
.object({
is_mask: z.boolean().optional(),
original_hash: z.string().optional(),
mask_type: z.string().optional(),
related_files: z
.object({
mask: z.string().optional(),
paint: z.string().optional(),
painted: z.string().optional()
})
.optional()
})
.optional()
type: z.string().optional()
})
export const zGetLogsData = z.object({
@@ -2774,6 +2892,115 @@ export const zGetJwksData = z.object({
*/
export const zGetJwksResponse = zJwksResponse
export const zGetOAuthAuthorizationServerData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Authorization-server metadata
*/
export const zGetOAuthAuthorizationServerResponse =
zOAuthAuthorizationServerMetadata
export const zGetOAuthProtectedResourceData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthProtectedResourceByPathData = z.object({
body: z.never().optional(),
path: z.object({
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
}),
query: z.never().optional()
})
/**
* Protected-resource metadata
*/
export const zGetOAuthProtectedResourceByPathResponse =
zOAuthProtectedResourceMetadata
export const zGetOAuthAuthorizeData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
response_type: z.string().optional(),
client_id: z.string().optional(),
redirect_uri: z.string().optional(),
scope: z.string().optional(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
resource: z.string().optional(),
oauth_request_id: z.string().optional()
})
.optional()
})
/**
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
*
*/
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
export const zPostOAuthAuthorizeData = z.object({
body: z.object({
oauth_request_id: z.string().uuid(),
csrf_token: z.string(),
decision: z.enum(['allow', 'deny']),
workspace_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
*/
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
export const zPostOAuthTokenData = z.object({
body: z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
client_id: z.string(),
code: z.string().optional(),
redirect_uri: z.string().optional(),
code_verifier: z.string().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
client_secret: z.string().optional()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* New token pair
*/
export const zPostOAuthTokenResponse = zOAuthTokenResponse
export const zPostOAuthRegisterData = z.object({
body: zOAuthRegisterRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
*/
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
export const zListWorkspacesData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3078,6 +3305,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Config inserted successfully
*/
export const zInsertDynamicConfigResponse = z.object({
id: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
.optional(),
message: z.string().optional()
})
export const zSyncApiKeyData = z.object({
body: zSyncApiKeyRequest,
path: z.never().optional(),
@@ -3671,12 +3920,6 @@ export const zGetHealthData = z.object({
*/
export const zGetHealthResponse = z.string()
export const zGetOpenapiSpecData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetMonitoringTasksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3757,6 +4000,16 @@ export const zPostCustomNodeProxyData = z.object({
query: z.never().optional()
})
export const zGetModelPreviewData = z.object({
body: z.never().optional(),
path: z.object({
folder: z.string(),
path_index: z.number().int(),
filename: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3832,3 +4085,150 @@ export const zGetLegacyViewMetadataData = z.object({
}),
query: z.never().optional()
})
export const zGetEmbeddingsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Embedding names
*/
export const zGetEmbeddingsResponse = z.array(z.string())
export const zFreeMemoryData = z.object({
body: z
.object({
unload_models: z.boolean().optional(),
free_memory: z.boolean().optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
export const zGetI18nData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Nested map of locale to translation key-value pairs
*/
export const zGetI18nResponse = z.record(z.unknown())
export const zGetInternalFolderPathsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Map of folder type name to list of path entries
*/
export const zGetInternalFolderPathsResponse = z.record(
z.array(z.array(z.string()))
)
export const zGetInternalLogsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Log text
*/
export const zGetInternalLogsResponse = z.string()
export const zGetInternalLogsRawData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Structured log data
*/
export const zGetInternalLogsRawResponse = z.object({
entries: z
.array(
z.object({
t: z.number().optional(),
m: z.string().optional()
})
)
.optional(),
size: z
.object({
cols: z.number().int().optional(),
rows: z.number().int().optional()
})
.optional()
})
export const zSubscribeToLogsData = z.object({
body: z.object({
clientId: z.string(),
enabled: z.boolean()
}),
path: z.never().optional(),
query: z.never().optional()
})
export const zPruneAssetsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Prune result
*/
export const zPruneAssetsResponse = z.object({
status: z.string().optional(),
marked: z.number().int().optional()
})
export const zSeedAssetsData = z.object({
body: z
.object({
roots: z.array(z.string()).optional()
})
.optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Seed started
*/
export const zSeedAssetsResponse = z.object({
status: z.string().optional()
})
export const zGetAssetSeedStatusData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan progress details (files scanned, total, status, etc.)
*/
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
export const zCancelAssetSeedData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Scan cancelled
*/
export const zCancelAssetSeedResponse = z.object({
status: z.string().optional()
})

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
@@ -46,7 +46,10 @@
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<div @contextmenu.capture="clearContextMenuTarget">
<div
class="min-w-0 overflow-x-hidden"
@contextmenu.capture="clearContextMenuTarget"
>
<DataTable
v-model:selection="selectedCommandData"
v-model:expanded-rows="expandedRows"
@@ -60,6 +63,7 @@
selection-mode="single"
context-menu
striped-rows
:table-style="{ tableLayout: 'fixed', width: '100%' }"
:pt="{
header: 'px-0'
}"
@@ -71,12 +75,11 @@
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1 truncate"
class="flex min-w-0 items-center gap-1 truncate"
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
:title="slotProps.data.id"
>
@@ -103,53 +106,38 @@
<Column
field="keybindings"
:header="$t('g.keybinding')"
:style="{ width: '30%' }"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
v-if="slotProps.data.keybindings.length > 0"
class="flex items-center gap-1"
>
<template
v-for="(binding, idx) in (
slotProps.data as ICommandData
).keybindings.slice(0, 2)"
:key="binding.combo.serialize()"
>
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="slotProps.data.isModified"
/>
</template>
<span
v-if="slotProps.data.keybindings.length > 2"
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{
$t('g.nMoreKeybindings', {
count: slotProps.data.keybindings.length - 2
})
}}
</span>
</div>
<span v-else>-</span>
<KeybindingList
:keybindings="slotProps.data.keybindings"
:is-modified="slotProps.data.isModified"
/>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:style="{ width: '16%' }"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
<span class="block truncate" :title="slotProps.data.source">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<Column
field="actions"
header=""
:style="{ width: '9rem' }"
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
>
<template #body="slotProps">
<div class="actions flex flex-row justify-end">
<div
class="actions flex flex-row justify-end whitespace-nowrap"
>
<Button
v-if="slotProps.data.keybindings.length === 1"
v-tooltip="$t('g.edit')"
@@ -330,6 +318,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeybindingList from './keybinding/KeybindingList.vue'
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'

View File

@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import KeybindingList from './KeybindingList.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
nMoreKeybindings: '+ {count} more',
nMoreKeybindingsCompact: '+ {count}',
keybindingListAriaLabel: 'Keybindings: {combos}'
}
}
}
})
function makeKeybinding(key: string, ctrl = false, shift = false) {
return new KeybindingImpl({
commandId: 'test.cmd',
combo: { key, ctrl, shift }
})
}
function renderList(props: {
keybindings: KeybindingImpl[]
isModified?: boolean
}) {
return render(KeybindingList, {
props,
global: { plugins: [i18n] }
})
}
describe('KeybindingList', () => {
it('renders "-" placeholder when there are no keybindings', () => {
renderList({ keybindings: [] })
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
})
it('renders a single keybinding without any "more" badge', () => {
renderList({ keybindings: [makeKeybinding('A', true)] })
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-wide')
).not.toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-medium')
).not.toBeInTheDocument()
expect(
screen.queryByTestId('keybinding-list-more-compact')
).not.toBeInTheDocument()
})
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
})
expect(
screen.queryByTestId('keybinding-list-more-wide')
).not.toBeInTheDocument()
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
'+ 1 more'
)
expect(
screen.getByTestId('keybinding-list-more-compact')
).toHaveTextContent('+ 1')
})
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
renderList({
keybindings: [
makeKeybinding('A', true),
makeKeybinding('B', true),
makeKeybinding('C', true)
]
})
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
'+ 1 more'
)
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
'+ 2 more'
)
expect(
screen.getByTestId('keybinding-list-more-compact')
).toHaveTextContent('+ 2')
})
it('uses a container query parent so the visible tier can adapt to width', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
})
expect(screen.getByTestId('keybinding-list').className).toContain(
'@container/keybindings'
)
})
it('emits an accessible label listing all combos', () => {
renderList({
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
})
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
expect(ariaText).toContain('Keybindings:')
expect(ariaText).toContain('Ctrl')
expect(ariaText).toContain('A')
expect(ariaText).toContain('Shift')
expect(ariaText).toContain('B')
})
})

View File

@@ -0,0 +1,74 @@
<template>
<span
v-if="keybindings.length > 0"
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
data-testid="keybinding-list"
>
<KeyComboDisplay
:key-combo="keybindings[0].combo"
:is-modified="isModified"
/>
<template v-if="keybindings.length >= 2">
<span
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
aria-hidden="true"
>
,
</span>
<KeyComboDisplay
class="hidden @[16rem]/keybindings:inline-flex"
:key-combo="keybindings[1].combo"
:is-modified="isModified"
/>
</template>
<span
v-if="keybindings.length > 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
data-testid="keybinding-list-more-wide"
>
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
</span>
<span
v-if="keybindings.length >= 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
data-testid="keybinding-list-more-medium"
>
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
</span>
<span
v-if="keybindings.length >= 2"
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
data-testid="keybinding-list-more-compact"
>
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
</span>
<span class="sr-only" data-testid="keybinding-list-aria">
{{ ariaLabel }}
</span>
</span>
<span v-else>-</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import KeyComboDisplay from './KeyComboDisplay.vue'
const { keybindings, isModified = false } = defineProps<{
keybindings: KeybindingImpl[]
isModified?: boolean
}>()
const { t } = useI18n()
const ariaLabel = computed(() => {
if (keybindings.length === 0) return ''
const combos = keybindings
.map((binding) => binding.combo.toString())
.join(', ')
return t('g.keybindingListAriaLabel', { combos })
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,13 @@
</template>
<template #end>
<div
class="flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2"
:class="
cn(
'flex flex-row overflow-hidden transition-all duration-200 [&_.p-button]:py-1 2xl:[&_.p-button]:py-2',
!props.toolButtonsAlwaysVisible &&
'motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100'
)
"
>
<slot name="tool-buttons" />
</div>
@@ -45,6 +51,7 @@ import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<{
title: string
class?: string
toolButtonsAlwaysVisible?: boolean
}>()
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetHoverPreview from './AssetHoverPreview.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
// An empty `tags` array yields no model type, so the node-preview section stays
// hidden — keeping the component free of the model-to-node store and the heavy
// NodePreview render for these presentational assertions.
const baseAsset: AssetItem = {
id: 'asset-1',
name: 'mymodel.safetensors',
tags: []
}
function renderPreview(asset: AssetItem) {
return render(AssetHoverPreview, {
global: {
plugins: [i18n],
directives: { tooltip: {} }
},
props: { asset }
})
}
describe('AssetHoverPreview', () => {
it('shows the description section when a description is present', () => {
renderPreview({
...baseAsset,
user_metadata: { user_description: 'A cutting-edge model.' }
})
expect(screen.getByText('Description')).toBeInTheDocument()
expect(screen.getByText('A cutting-edge model.')).toBeInTheDocument()
})
it('hides the description section when the description is empty', () => {
renderPreview({ ...baseAsset, user_metadata: { user_description: '' } })
expect(screen.queryByText('Description')).toBeNull()
})
it('renders trigger words as chips under a labelled section', () => {
renderPreview({
...baseAsset,
metadata: { trained_words: ['cat', 'digital art'] }
})
expect(screen.getByText('Trigger words')).toBeInTheDocument()
expect(screen.getByText('cat')).toBeInTheDocument()
expect(screen.getByText('digital art')).toBeInTheDocument()
})
it('omits the trigger words section when there are none', () => {
renderPreview(baseAsset)
expect(screen.queryByText('Trigger words')).toBeNull()
})
})

View File

@@ -0,0 +1,270 @@
<template>
<div
class="flex w-96 flex-col gap-2 overflow-hidden rounded-2xl border border-border-default bg-comfy-menu-bg p-4 text-sm text-base-foreground shadow-lg"
>
<!-- Header -->
<div class="flex w-full items-start gap-2 pb-1">
<div class="flex min-w-0 flex-1 flex-col items-start gap-2">
<div
class="flex w-full flex-col gap-1.5 pr-2 leading-tight wrap-break-word"
>
<span class="font-medium">{{ displayName }}</span>
<span
v-if="filename"
class="font-normal break-all text-muted-foreground"
>
{{ filename }}
</span>
</div>
<div
v-if="baseModels.length || sourceUrl"
class="flex w-full flex-wrap items-start gap-2 pb-1"
>
<span
v-for="baseModel in baseModels"
:key="baseModel"
class="inline-flex h-6 max-w-full items-center rounded-full bg-secondary-background px-2 py-1 text-xs text-base-foreground"
>
<span class="truncate">{{ baseModel }}</span>
</span>
<Button
v-if="sourceUrl"
v-tooltip.bottom="$t('cloudModelLibrary.preview.openUrl')"
variant="secondary"
size="sm"
class="h-6 shrink-0 gap-1 rounded-full px-2 font-normal text-base-foreground"
:aria-label="$t('cloudModelLibrary.preview.openUrl')"
@click="openSourceUrl"
>
{{ $t('cloudModelLibrary.preview.url') }}
<i class="icon-[lucide--external-link] size-3.5" />
</Button>
</div>
</div>
<div
v-if="isCloud"
class="relative size-27 shrink-0 overflow-hidden rounded-sm bg-muted-background"
>
<template v-if="thumbnail">
<Skeleton v-if="!thumbnailLoaded" class="absolute inset-0" />
<img
:src="thumbnail.src"
:alt="displayName"
class="size-full object-cover transition-opacity duration-150"
:class="thumbnailLoaded ? 'opacity-100' : 'opacity-0'"
@load="thumbnailLoaded = true"
@error="onMediaError"
/>
</template>
<CategoryPlaceholder v-else :category="placeholderCategory" />
</div>
</div>
<!-- Divider: header / description -->
<div v-if="description" class="-mx-4 border-t border-border-default" />
<!-- Description -->
<div v-if="description" class="flex w-full flex-col gap-2 py-2">
<span
class="text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.description') }}
</span>
<p
class="max-h-24 scrollbar-thin overflow-y-auto wrap-break-word text-muted-foreground"
>
{{ description }}
</p>
</div>
<!-- Trigger words -->
<div v-if="triggerPhrases.length" class="flex w-full flex-col gap-2 pb-2">
<div class="flex items-center gap-2.5">
<span
class="flex-1 text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.triggerWords') }}
</span>
<Button
v-tooltip.top="$t('g.copyAll')"
variant="muted-textonly"
size="icon"
class="rounded-lg"
:aria-label="$t('g.copyAll')"
@click="copyText(triggerPhrases.join(', '))"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="phrase in triggerPhrases"
:key="phrase"
v-tooltip.bottom="
copiedPhrase === phrase ? $t('g.copied') : $t('g.copyToClipboard')
"
variant="secondary"
size="sm"
class="h-6 rounded-full px-2 font-normal text-base-foreground"
@click="copyTriggerPhrase(phrase, $event)"
>
{{ truncatePhrase(phrase) }}
</Button>
</div>
</div>
<!-- Divider: metadata / node preview -->
<div v-if="previewNodeDef" class="-mx-4 border-t border-border-default" />
<!-- Node preview -->
<div v-if="previewNodeDef" class="flex w-full flex-col gap-2">
<span
class="mt-2 text-xs font-bold tracking-wide text-muted-foreground uppercase"
>
{{ $t('cloudModelLibrary.preview.nodePreview') }}
</span>
<div class="flex w-full justify-center py-2.5">
<div
ref="previewContainerRef"
class="overflow-hidden"
:style="{ width: `${NODE_PREVIEW_WIDTH_PX}px` }"
>
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${nodePreviewScale})` }"
>
<LGraphNodePreview :node-def="previewNodeDef" position="relative" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import CategoryPlaceholder from '@/components/sidebar/tabs/cloudModelLibrary/CategoryPlaceholder.vue'
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { placeholderCategoryForAsset } from '@/composables/sidebarTabs/useCategoryPlaceholder'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription
} from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const { asset } = defineProps<{ asset: AssetItem }>()
const rawDisplayName = computed(() => getAssetDisplayName(asset))
const displayName = computed(() => formatRowDisplayName(rawDisplayName.value))
const filename = computed(() => {
const value = getAssetFilename(asset)
return value && value !== rawDisplayName.value ? value : ''
})
const baseModels = computed(() => getAssetBaseModels(asset))
const description = computed(() => getAssetUserDescription(asset))
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
const nativePreviewUrl = computed(
() => asset.preview_url ?? asset.thumbnail_url ?? ''
)
const nativeErrored = ref(false)
watch(nativePreviewUrl, () => {
nativeErrored.value = false
})
const thumbnail = computed(() =>
nativePreviewUrl.value && !nativeErrored.value
? { src: nativePreviewUrl.value }
: null
)
const thumbnailLoaded = ref(false)
watch(
() => thumbnail.value?.src,
() => {
thumbnailLoaded.value = false
}
)
const placeholderCategory = computed(() => placeholderCategoryForAsset(asset))
function onMediaError() {
nativeErrored.value = true
}
const sourceUrl = computed(() => getAssetSourceUrl(asset))
function openSourceUrl() {
if (!sourceUrl.value) return
window.open(sourceUrl.value, '_blank', 'noopener,noreferrer')
}
// The plain Load node for the asset's category — surfaced as a live preview so
// the user sees the result before inserting.
const previewNodeDef = computed(() => {
const category = getAssetModelType(asset)
if (!category) return null
return useModelToNodeStore().getNodeProvider(category)?.nodeDef ?? null
})
// LGraphNodePreview renders at a fixed 350px; scale it to the Figma node-preview
// width and compensate the container height so the CSS transform doesn't leave
// empty space below the node.
const NODE_PREVIEW_WIDTH_PX = 268
const NODE_BASE_WIDTH_PX = 350
const nodePreviewScale = NODE_PREVIEW_WIDTH_PX / NODE_BASE_WIDTH_PX
const previewContainerRef = ref<HTMLElement>()
const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
previewContainerRef.value.style.height = `${entry.contentRect.height * nodePreviewScale}px`
}
})
async function copyText(text: string) {
await navigator.clipboard.writeText(text)
}
// Tracks the trigger word most recently copied so its tooltip can flip to
// "Copied" as confirmation.
const copiedPhrase = ref<string | null>(null)
let copiedResetTimer: ReturnType<typeof setTimeout> | null = null
const COPIED_FEEDBACK_MS = 1500
async function copyTriggerPhrase(phrase: string, event: MouseEvent) {
const target = event.currentTarget
await copyText(phrase)
copiedPhrase.value = phrase
// PrimeVue hides the tooltip on click and doesn't refresh a visible tooltip's
// text, so re-trigger it to surface the updated "Copied" label in place.
await nextTick()
if (target instanceof HTMLElement)
target.dispatchEvent(new MouseEvent('mouseenter'))
if (copiedResetTimer) clearTimeout(copiedResetTimer)
copiedResetTimer = setTimeout(() => {
copiedPhrase.value = null
copiedResetTimer = null
}, COPIED_FEEDBACK_MS)
}
onBeforeUnmount(() => {
if (copiedResetTimer) clearTimeout(copiedResetTimer)
})
const TRIGGER_PHRASE_MAX_LENGTH = 20
function truncatePhrase(phrase: string): string {
return phrase.length > TRIGGER_PHRASE_MAX_LENGTH
? `${phrase.slice(0, TRIGGER_PHRASE_MAX_LENGTH)}`
: phrase
}
</script>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { placeholderGradientForCategory } from '@/composables/sidebarTabs/useCategoryPlaceholder'
const { category } = defineProps<{ category: string }>()
const background = computed(() => placeholderGradientForCategory(category))
</script>
<template>
<div class="size-full" :style="{ background }" />
</template>

View File

@@ -0,0 +1,125 @@
<template>
<ContextMenuRoot v-model:open="isContextMenuOpen">
<ContextMenuTrigger as-child>
<div
ref="rowRef"
:class="LEAF_ROW_CLASS"
:data-asset-id="asset.id"
role="listitem"
tabindex="0"
@dblclick="handleActivate"
@keydown.enter.prevent="handleActivate"
>
<i
class="icon-[comfy--ai-model] size-4 shrink-0 text-muted-foreground"
/>
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
{{ displayName }}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="LEAF_MENU_CONTENT_CLASS">
<ContextMenuItem :class="LEAF_MENU_ITEM_CLASS" @select="handleActivate">
<i class="icon-[comfy--node] size-4" />
{{ $t('cloudModelLibrary.contextMenu.addToGraph') }}
</ContextMenuItem>
<ContextMenuItem
:class="LEAF_MENU_ITEM_CLASS"
@select="handleCopyFilename"
>
<i class="icon-[lucide--copy] size-4" />
{{ $t('cloudModelLibrary.contextMenu.copyFilename') }}
</ContextMenuItem>
<ContextMenuItem
v-if="huggingFaceUrl"
:class="LEAF_MENU_ITEM_CLASS"
@select="openHuggingFace"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('cloudModelLibrary.contextMenu.openOnHuggingFace') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<script setup lang="ts">
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger
} from 'reka-ui'
import { computed } from 'vue'
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { useNodePreviewDragImage } from '@/components/sidebar/tabs/cloudModelLibrary/useNodePreviewDragImage'
import {
LEAF_MENU_CONTENT_CLASS,
LEAF_MENU_ITEM_CLASS,
LEAF_ROW_CLASS,
useModelLibraryLeaf
} from '@/composables/sidebarTabs/useModelLibraryLeaf'
import { usePragmaticDraggable } from '@/composables/usePragmaticDragAndDrop'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl
} from '@/platform/assets/utils/assetMetadataUtils'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const { asset } = defineProps<{
asset: AssetItem
}>()
const emit = defineEmits<{
activate: [asset: AssetItem]
// Emitted on mouseenter/leave with the row's bounding rect. The parent owns
// the single shared hover popover and uses the rect for positioning.
hoverChange: [payload: { asset: AssetItem; rect: DOMRect } | { asset: null }]
}>()
const displayName = computed(() =>
formatRowDisplayName(getAssetDisplayName(asset))
)
const hide = () => emit('hoverChange', { asset: null })
const { rowRef, isContextMenuOpen } = useModelLibraryLeaf({
onShow: (rect) => emit('hoverChange', { asset, rect }),
onHide: hide
})
const huggingFaceUrl = computed(() => {
const url = getAssetSourceUrl(asset)
return url && url.includes('huggingface.co') ? url : ''
})
const handleCopyFilename = async () => {
await navigator.clipboard.writeText(getAssetFilename(asset))
}
const openHuggingFace = () => {
if (!huggingFaceUrl.value) return
window.open(huggingFaceUrl.value, '_blank', 'noopener,noreferrer')
}
const handleActivate = () => {
emit('activate', asset)
}
const onGenerateDragPreview = useNodePreviewDragImage(() => {
const category = getAssetModelType(asset)
return category
? (useModelToNodeStore().getNodeProvider(category)?.nodeDef ?? null)
: null
})
usePragmaticDraggable(() => rowRef.value, {
getInitialData: () => ({ type: 'cloud-model-asset', asset }),
onGenerateDragPreview,
onDragStart: hide
})
</script>

View File

@@ -0,0 +1,587 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.modelLibrary')"
tool-buttons-always-visible
>
<template #tool-buttons>
<div class="flex items-center gap-2">
<Button
v-tooltip.bottom="$t('g.refresh')"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.refresh')"
@click="refreshAssets"
>
<i class="icon-[lucide--refresh-cw] size-4" />
</Button>
<Button
v-if="isUploadButtonEnabled"
variant="inverted"
data-attr="model-library-import-button"
@click="showUploadDialog"
>
<i class="icon-[lucide--folder-input] size-4" />
<span>{{ $t('assetBrowser.uploadModel') }}</span>
</Button>
</div>
</template>
<template #header>
<SidebarTopArea>
<SearchInput
v-model="searchQuery"
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
/>
<template #actions>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.bottom="$t('assets.sort.tooltip')"
variant="secondary"
size="icon"
:aria-label="$t('assets.sort.tooltip')"
>
<i class="icon-[lucide--arrow-down-up] size-4" />
</Button>
</template>
<template #default>
<div class="flex min-w-44 flex-col">
<Button
v-for="option in SORT_OPTIONS"
:key="option.value"
variant="textonly"
class="w-full justify-between"
@click="sortMode = option.value"
>
<span>{{ $t(option.labelKey) }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortMode !== option.value && 'opacity-0'"
/>
</Button>
</div>
</template>
</Popover>
</template>
</SidebarTopArea>
</template>
<template #body>
<div
v-if="isLoading"
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
{{ $t('g.loading') }}
</div>
<div
v-else-if="!sections.length"
class="flex h-full items-center justify-center px-4 text-center text-xs text-muted-foreground"
>
{{ $t('assetBrowser.noResultsCanImport') }}
</div>
<div v-else class="flex flex-col">
<template v-for="(section, sectionIndex) in sections" :key="section.id">
<button
type="button"
class="group/tree-node flex w-full min-w-0 cursor-pointer items-center gap-3 overflow-hidden rounded-sm border-0 bg-transparent py-2 pl-2 text-left outline-none select-none hover:bg-comfy-input"
:aria-expanded="isExpanded(section.id)"
:aria-controls="`cloud-model-section-${section.id}`"
@click="setExpanded(section.id, !isExpanded(section.id))"
>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded(section.id) && '-rotate-90'
)
"
/>
<i
class="icon-[lucide--folder] size-4 shrink-0 text-muted-foreground"
/>
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
{{ section.label }}
</span>
<span class="shrink-0 pr-2 text-2xs text-muted-foreground">
{{ section.totalCount }}
</span>
</button>
<div
v-if="isExpanded(section.id)"
:id="`cloud-model-section-${section.id}`"
class="flex flex-col"
role="list"
>
<template v-for="pg in section.providers" :key="pg.provider">
<div
v-if="section.providers.length > 1"
class="pt-2 pr-2 pb-0.5 pl-8 text-3xs font-medium tracking-wide text-muted-foreground uppercase"
>
{{ pg.provider }}
</div>
<template v-for="item in pg.items" :key="itemKey(item)">
<CloudModelLeaf
v-if="item.kind === 'asset'"
:asset="item.asset"
@activate="handleAssetActivate"
@hover-change="handleAssetHoverChange"
/>
<CloudPartnerLeaf
v-else
:node-def="item.nodeDef"
@activate="handlePartnerActivate"
@hover-change="handlePartnerHoverChange"
/>
</template>
</template>
</div>
<div
v-if="
sectionIndex === lastPinnedSectionIndex &&
sectionIndex < sections.length - 1
"
class="mx-6 my-2 border-t border-border-default/40"
/>
</template>
</div>
</template>
</SidebarTabTemplate>
<teleport v-if="hoveredItem" to="body">
<div
ref="hoverPopoverRef"
class="fixed z-999"
:style="hoverPopoverStyle"
@pointerdown="handlePopoverEnter"
@mouseenter="handlePopoverEnter"
@mouseleave="handlePopoverLeave"
>
<AssetHoverPreview
v-if="hoveredItem.kind === 'asset'"
:asset="hoveredItem.asset"
/>
<PartnerNodeHoverPreview v-else :node-def="hoveredItem.nodeDef" />
</div>
</teleport>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import AssetHoverPreview from '@/components/sidebar/tabs/cloudModelLibrary/AssetHoverPreview.vue'
import CloudModelLeaf from '@/components/sidebar/tabs/cloudModelLibrary/CloudModelLeaf.vue'
import CloudPartnerLeaf from '@/components/sidebar/tabs/cloudModelLibrary/CloudPartnerLeaf.vue'
import PartnerNodeHoverPreview from '@/components/sidebar/tabs/cloudModelLibrary/PartnerNodeHoverPreview.vue'
import {
MODEL_GROUPS,
PARTNER_NODES_GROUP_ID,
fallbackGroupLabel,
formatPartnerProvider,
getAssetProvider,
isPartnerNodeCategory
} from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import {
firstNonModelsTag,
groupIdForAsset,
groupLabelForAsset,
partnerKind,
rawTagTopLevel
} from '@/components/sidebar/tabs/cloudModelLibrary/modelLibraryGrouping'
import { buildProviderGroups } from '@/components/sidebar/tabs/cloudModelLibrary/modelLibrarySort'
import type {
Section,
SidebarItem,
SortMode
} from '@/components/sidebar/tabs/cloudModelLibrary/modelLibrarySort'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useModelLibraryHoverPopover } from '@/composables/sidebarTabs/useModelLibraryHoverPopover'
import { useModelLibrarySource } from '@/composables/sidebarTabs/useModelLibrarySource'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetTriggerPhrases
} from '@/platform/assets/utils/assetMetadataUtils'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { isCloud } from '@/platform/distribution/types'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { cn } from '@comfyorg/tailwind-utils'
// Surface the most important categories at the top of the library, in this
// exact order, ahead of the alphabetically-sorted long tail.
const PINNED_GROUP_IDS: readonly string[] = [
'diffusion',
'loras',
PARTNER_NODES_GROUP_ID
]
const { t } = useI18n()
const toast = useToast()
const nodeDefStore = useNodeDefStore()
const litegraphService = useLitegraphService()
// Single unified Model Library source. The cloud distribution reads the live
// assets API; desktop/localhost enumerates the on-disk models folder. Both
// surface the same AssetItem[] shape so this component renders without
// branching on distribution.
const source = useModelLibrarySource()
// Mirrors the asset-browser modal's Import action: a header CTA that opens the
// model upload dialog. Gated on the same feature flag as the modal button, so
// it only surfaces where uploading models is supported (cloud).
const { isUploadButtonEnabled, showUploadDialog } =
useModelUpload(refreshAssets)
const searchQuery = ref('')
const ALL_SORT_OPTIONS: ReadonlyArray<{ value: SortMode; labelKey: string }> = [
{ value: 'baseModelAsc', labelKey: 'assets.sort.baseModelAsc' },
{ value: 'baseModelDesc', labelKey: 'assets.sort.baseModelDesc' },
{ value: 'recent', labelKey: 'assets.sort.recent' },
{ value: 'oldest', labelKey: 'assets.sort.oldest' },
{ value: 'nameAsc', labelKey: 'assets.sort.nameAsc' },
{ value: 'nameDesc', labelKey: 'assets.sort.nameDesc' }
] as const
// Base-model sort/grouping relies on reliable base-model metadata, which only
// the cloud assets API provides; local builds list models alphabetically.
const SORT_OPTIONS = isCloud
? ALL_SORT_OPTIONS
: ALL_SORT_OPTIONS.filter(
(option) =>
option.value !== 'baseModelAsc' && option.value !== 'baseModelDesc'
)
const sortMode = useStorage<SortMode>(
'Comfy.CloudModelLibrary.SortBy',
isCloud ? 'baseModelAsc' : 'nameAsc'
)
// A base-model sort persisted earlier (or shared with the cloud build via the
// same storage key) must not survive on local, where the option is hidden.
if (
!isCloud &&
(sortMode.value === 'baseModelAsc' || sortMode.value === 'baseModelDesc')
) {
sortMode.value = 'nameAsc'
}
const expanded = ref<Record<string, boolean>>({})
const expandedBeforeSearch = ref<Record<string, boolean>>({})
const assets = computed<AssetItem[]>(() => source.assets.value)
const partnerNodes = computed<ComfyNodeDefImpl[]>(() =>
nodeDefStore.visibleNodeDefs.filter(
(def) => def.api_node || isPartnerNodeCategory(def.category)
)
)
const isLoading = computed(
() => source.isLoading.value && assets.value.length === 0
)
// Weights are tiered so name/filename matches dominate. Secondary metadata
// (tags, provider, baseModels, etc.) only breaks ties — never outranks an
// asset whose name actually contains the query.
const assetFuseOptions: UseFuseOptions<AssetItem> = {
fuseOptions: {
keys: [
{ name: 'name', weight: 1.0 },
{ name: 'user_metadata.name', weight: 1.0 },
{ name: 'metadata.name', weight: 0.9 },
{ name: 'metadata.filename', weight: 0.9 },
{ name: 'metadata.filepath', weight: 0.4 },
{ name: 'metadata.repo_id', weight: 0.5 },
{ name: 'tags', weight: 0.15 },
{ name: 'user_metadata.user_description', weight: 0.1 },
{
name: 'provider',
weight: 0.15,
getFn: (asset) => getAssetProvider(asset)
},
{
name: 'group',
weight: 0.15,
getFn: (asset) => groupLabelForAsset(asset)
},
{
name: 'baseModels',
weight: 0.2,
getFn: (asset) => getAssetBaseModels(asset)
},
{
name: 'trainedWords',
weight: 0.15,
getFn: (asset) => getAssetTriggerPhrases(asset)
}
],
threshold: 0.3,
ignoreLocation: true,
includeScore: true
},
matchAllWhenSearchEmpty: true
}
const partnerFuseOptions: UseFuseOptions<ComfyNodeDefImpl> = {
fuseOptions: {
keys: [
{ name: 'display_name', weight: 0.5 },
{ name: 'name', weight: 0.3 },
{ name: 'category', weight: 0.2 },
{ name: 'description', weight: 0.2 },
{
name: 'provider',
weight: 0.4,
getFn: (nodeDef) => formatPartnerProvider(nodeDef.category)
},
{
name: 'kind',
weight: 0.3,
getFn: (nodeDef) => partnerKind(nodeDef.category)
}
],
threshold: 0.4,
ignoreLocation: true,
includeScore: true
},
matchAllWhenSearchEmpty: true
}
const { results: assetFuseResults } = useFuse(
searchQuery,
assets,
assetFuseOptions
)
const { results: partnerFuseResults } = useFuse(
searchQuery,
partnerNodes,
partnerFuseOptions
)
const matchedAssets = computed(() =>
assetFuseResults.value.map((result) => result.item)
)
const matchedPartners = computed(() =>
partnerFuseResults.value.map((result) => result.item)
)
const sections = computed<Section[]>(() => {
const isSearching = searchQuery.value.trim().length > 0
const mode = sortMode.value
// With an active search, collapse category sections into a single flat
// "Search results" list ordered by Fuse relevance across both pools
// (assets and partner nodes). Lower score = better match.
if (isSearching) {
type Scored = { score: number; item: SidebarItem }
const merged: Scored[] = []
for (const r of assetFuseResults.value) {
merged.push({
score: r.score ?? 1,
item: { kind: 'asset', asset: r.item }
})
}
for (const r of partnerFuseResults.value) {
merged.push({
score: r.score ?? 1,
item: { kind: 'partner', nodeDef: r.item }
})
}
if (merged.length === 0) return []
merged.sort((a, b) => a.score - b.score)
return [
{
id: 'search-results',
label: t('assets.searchResults'),
providers: [{ provider: '', items: merged.map((m) => m.item) }],
totalCount: merged.length
}
]
}
const knownGroups = MODEL_GROUPS.filter(
(g) => g.id !== PARTNER_NODES_GROUP_ID
)
const assetsByGroup = new Map<string, AssetItem[]>()
const unmappedByTag = new Map<string, AssetItem[]>()
for (const asset of matchedAssets.value) {
const tag = firstNonModelsTag(asset)
if (!tag) continue
const top = rawTagTopLevel(tag)
// groupIdForAsset applies the base-model category override (e.g. an
// ACE-Step text encoder lands under "TTS & audio" with its base, not
// "Encoders"). Falls back to the tag-derived group for assets with no
// resolvable base.
const groupId = groupIdForAsset(asset)
if (groupId) {
const list = assetsByGroup.get(groupId) ?? []
list.push(asset)
assetsByGroup.set(groupId, list)
} else {
const list = unmappedByTag.get(top) ?? []
list.push(asset)
unmappedByTag.set(top, list)
}
}
const filteredPartners = matchedPartners.value
const result: Section[] = []
// The curated PINNED_GROUP_IDS render first in their declared order
// (Diffusion LoRAs Partner nodes); everything else interleaves
// alphabetically below.
const makeAssetSection = (
id: string,
label: string,
list: AssetItem[]
): Section | null => {
if (list.length === 0) return null
const items: SidebarItem[] = list.map((asset) => ({ kind: 'asset', asset }))
return {
id,
label,
providers: buildProviderGroups(items, mode, isSearching),
totalCount: items.length
}
}
const buildSection = (id: string): Section | null => {
if (id === PARTNER_NODES_GROUP_ID) {
if (filteredPartners.length === 0) return null
const items: SidebarItem[] = filteredPartners.map((nodeDef) => ({
kind: 'partner',
nodeDef
}))
return {
id: PARTNER_NODES_GROUP_ID,
label: t('sideToolbar.nodeLibraryTab.sections.partnerNodes'),
providers: buildProviderGroups(items, mode, isSearching),
totalCount: items.length
}
}
const group = MODEL_GROUPS.find((g) => g.id === id)
if (!group) return null
return makeAssetSection(
group.id,
group.label,
assetsByGroup.get(group.id) ?? []
)
}
const pinnedSections: Section[] = []
for (const id of PINNED_GROUP_IDS) {
const section = buildSection(id)
if (section) pinnedSections.push(section)
}
type PendingSection = { sortKey: string; section: Section }
const pending: PendingSection[] = []
const collect = (section: Section | null) => {
if (section) pending.push({ sortKey: section.label, section })
}
for (const group of knownGroups) {
if (PINNED_GROUP_IDS.includes(group.id)) continue
collect(
makeAssetSection(group.id, group.label, assetsByGroup.get(group.id) ?? [])
)
}
for (const tag of unmappedByTag.keys()) {
collect(
makeAssetSection(
`tag:${tag}`,
fallbackGroupLabel(tag),
unmappedByTag.get(tag) ?? []
)
)
}
pending.sort((a, b) =>
a.sortKey.localeCompare(b.sortKey, undefined, { sensitivity: 'base' })
)
for (const section of pinnedSections) result.push(section)
for (const { section } of pending) result.push(section)
return result
})
// Index of the last pinned section — used by the template to render a
// delimiter between the curated stack and the alphabetical long tail.
const lastPinnedSectionIndex = computed<number>(() => {
let lastIndex = -1
for (let i = 0; i < sections.value.length; i++) {
if (PINNED_GROUP_IDS.includes(sections.value[i].id)) lastIndex = i
}
return lastIndex
})
const isExpanded = (id: string) => Boolean(expanded.value[id])
const setExpanded = (id: string, open: boolean) => {
expanded.value = { ...expanded.value, [id]: open }
}
function itemKey(item: SidebarItem): string {
return item.kind === 'asset' ? `a:${item.asset.id}` : `n:${item.nodeDef.name}`
}
watch(searchQuery, (next, prev) => {
const wasSearching = prev.trim().length > 0
const nowSearching = next.trim().length > 0
if (!wasSearching && nowSearching) {
expandedBeforeSearch.value = { ...expanded.value }
const expandAll: Record<string, boolean> = {}
for (const section of sections.value) expandAll[section.id] = true
expanded.value = expandAll
} else if (wasSearching && !nowSearching) {
expanded.value = { ...expandedBeforeSearch.value }
}
})
async function refreshAssets(): Promise<void> {
await source.refresh()
}
const handleAssetActivate = (asset: AssetItem) => {
const result = createModelNodeFromAsset(asset)
if (!result.success) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode'),
life: 4000
})
}
}
const handlePartnerActivate = (nodeDef: ComfyNodeDefImpl) => {
litegraphService.addNodeOnGraph(nodeDef)
}
const hoverPopoverRef = ref<HTMLElement | null>(null)
const {
hoveredItem,
hoverPopoverStyle,
handleAssetHoverChange,
handlePartnerHoverChange,
handlePopoverEnter,
handlePopoverLeave
} = useModelLibraryHoverPopover(hoverPopoverRef)
onMounted(() => {
void refreshAssets()
})
</script>

View File

@@ -0,0 +1,103 @@
<template>
<ContextMenuRoot v-model:open="isContextMenuOpen">
<ContextMenuTrigger as-child>
<div
ref="rowRef"
:class="LEAF_ROW_CLASS"
:data-node-name="nodeDef.name"
role="listitem"
tabindex="0"
@dblclick="handleActivate"
@keydown.enter.prevent="handleActivate"
>
<i
:class="
cn(
'size-4 shrink-0',
hasBrandIcon
? brandIconClass
: 'icon-[lucide--cloud] text-muted-foreground'
)
"
/>
<span class="text-foreground min-w-0 flex-1 truncate text-sm">
{{ nodeDef.display_name }}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="LEAF_MENU_CONTENT_CLASS">
<ContextMenuItem :class="LEAF_MENU_ITEM_CLASS" @select="handleActivate">
<i class="icon-[comfy--node] size-4" />
{{ $t('cloudModelLibrary.contextMenu.addToGraph') }}
</ContextMenuItem>
<ContextMenuItem
:class="LEAF_MENU_ITEM_CLASS"
@select="handleCopyNodeName"
>
<i class="icon-[lucide--copy] size-4" />
{{ $t('cloudModelLibrary.contextMenu.copyNodeName') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<script setup lang="ts">
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger
} from 'reka-ui'
import { computed } from 'vue'
import { formatPartnerProvider } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { useNodePreviewDragImage } from '@/components/sidebar/tabs/cloudModelLibrary/useNodePreviewDragImage'
import {
LEAF_MENU_CONTENT_CLASS,
LEAF_MENU_ITEM_CLASS,
LEAF_ROW_CLASS,
useModelLibraryLeaf
} from '@/composables/sidebarTabs/useModelLibraryLeaf'
import { usePragmaticDraggable } from '@/composables/usePragmaticDragAndDrop'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { getProviderIcon, hasProviderIcon } from '@/utils/categoryUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { nodeDef } = defineProps<{ nodeDef: ComfyNodeDefImpl }>()
const emit = defineEmits<{
activate: [nodeDef: ComfyNodeDefImpl]
// Mirrors CloudModelLeaf — parent owns the shared hover popover.
hoverChange: [
payload: { nodeDef: ComfyNodeDefImpl; rect: DOMRect } | { nodeDef: null }
]
}>()
const provider = computed(() => formatPartnerProvider(nodeDef.category))
const hasBrandIcon = computed(() => hasProviderIcon(provider.value))
const brandIconClass = computed(() => getProviderIcon(provider.value))
const hide = () => emit('hoverChange', { nodeDef: null })
const { rowRef, isContextMenuOpen } = useModelLibraryLeaf({
onShow: (rect) => emit('hoverChange', { nodeDef, rect }),
onHide: hide
})
const handleCopyNodeName = async () => {
await navigator.clipboard.writeText(nodeDef.display_name || nodeDef.name)
}
const handleActivate = () => {
emit('activate', nodeDef)
}
const onGenerateDragPreview = useNodePreviewDragImage(() => nodeDef)
usePragmaticDraggable(() => rowRef.value, {
getInitialData: () => ({ type: 'partner-node', nodeDef }),
onGenerateDragPreview,
onDragStart: hide
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex w-96 flex-col gap-2 rounded-xl border border-border-default bg-comfy-menu-bg p-3 text-xs text-base-foreground shadow-lg"
>
<div
v-if="provider || kind"
class="flex items-center gap-1.5 text-2xs tracking-wide text-muted-foreground uppercase"
>
<span v-if="provider">{{ provider }}</span>
<span v-if="provider && kind" class="opacity-60">·</span>
<span v-if="kind">{{ kind }}</span>
</div>
<div class="text-sm font-semibold">{{ nodeDef.display_name }}</div>
<div v-if="nodeDef.description" class="text-muted-foreground">
{{ nodeDef.description }}
</div>
<div
class="-mx-3 mt-1 -mb-3 flex flex-col gap-1.5 border-t border-border-default bg-muted-background/40 p-3 pt-2"
>
<div class="text-2xs tracking-wide text-muted-foreground uppercase">
{{ $t('cloudModelLibrary.preview.createsNode') }}
</div>
<div class="flex justify-center">
<NodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { formatPartnerProvider } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { partnerKind } from '@/components/sidebar/tabs/cloudModelLibrary/modelLibraryGrouping'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const { nodeDef } = defineProps<{ nodeDef: ComfyNodeDefImpl }>()
const provider = computed(() => formatPartnerProvider(nodeDef.category))
const kind = computed(() => partnerKind(nodeDef.category))
</script>

View File

@@ -0,0 +1,80 @@
/**
* Maps a canonical base-model label to the category group its assets should
* land under, regardless of the asset's file-type tag. Use when a base-model
* family's companions (text encoders, VAEs, model patches, etc.) should be
* displayed alongside the base instead of scattered across encoder / vae /
* conditioning buckets.
*
* LoRAs are exempt — they always stay in the dedicated "LoRAs" group, since
* LoRA is a cross-family file format and the [[Base model]] sort axis already
* groups them by family.
*
* Family roots that span multiple modalities (e.g. bare "Qwen" can be either
* a language model or an image model) are intentionally omitted; their tags
* already classify correctly.
*/
const BASE_MODEL_CATEGORY_OVERRIDES: Readonly<Record<string, string>> =
Object.freeze({
// Audio bases
'ACE-Step': 'audio',
'Stable Audio': 'audio',
// Video & motion bases
Wan: 'video',
'Wan 2.1': 'video',
'Wan 2.2': 'video',
HunyuanVideo: 'video',
'HunyuanVideo 1.5': 'video',
'LTX Video': 'video',
'LTX 2': 'video',
'LTX 2.3': 'video',
CogVideo: 'video',
Mochi: 'video',
Cosmos: 'video',
HuMo: 'video',
AnimateDiff: 'video',
// Image diffusion bases — encoders/VAEs/checkpoints stay with the base
'Flux.1 dev': 'diffusion',
'Flux.1 Krea': 'diffusion',
'Flux.1 Kontext': 'diffusion',
'Flux.1 Redux': 'diffusion',
'Flux.1 Schnell': 'diffusion',
'Flux.2 dev': 'diffusion',
'Flux.2 Klein': 'diffusion',
'SD 1.5': 'diffusion',
'SD 2': 'diffusion',
'SD 2.1': 'diffusion',
'SD 3': 'diffusion',
'SD 3.5': 'diffusion',
SDXL: 'diffusion',
Pony: 'diffusion',
Illustrious: 'diffusion',
Chroma: 'diffusion',
'Chroma1 HD': 'diffusion',
'Chroma1 Radiance': 'diffusion',
HiDream: 'diffusion',
'HiDream I1': 'diffusion',
'HiDream O1': 'diffusion',
'Z-Image': 'diffusion',
'Qwen Image': 'diffusion',
'Qwen Image Edit': 'diffusion',
'Hunyuan Image': 'diffusion',
Lumina: 'diffusion',
Kolors: 'diffusion',
AuraFlow: 'diffusion',
PixArt: 'diffusion',
Kandinsky: 'diffusion',
Playground: 'diffusion',
ERNIE: 'diffusion',
Omnigen: 'diffusion',
LongCat: 'diffusion',
NewBie: 'diffusion',
Ovis: 'diffusion',
UltraShape: 'diffusion',
OneReward: 'diffusion',
USO: 'diffusion',
PixelDiT: 'diffusion'
})
export function getCategoryOverrideForBase(label: string): string | null {
return BASE_MODEL_CATEGORY_OVERRIDES[label] ?? null
}

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
inferBaseModelFromText,
refineBaseModelLabels
} from './baseModelInference'
describe('inferBaseModelFromText', () => {
it.for<{ name: string; expected: string | null }>([
{
name: 'flux1-disney_renaissance_style.safetensors',
expected: 'Flux.1 dev'
},
{ name: 'flux1-arcane_style.safetensors', expected: 'Flux.1 dev' },
{ name: 'flux2-klein-9b-some-thing.safetensors', expected: 'Flux.2 Klein' },
{ name: 'zimage-oldschool_hud_graphics.safetensors', expected: 'Z-Image' },
{ name: 'ZImageTurbo', expected: 'Z-Image' },
{ name: 'Z-Image', expected: 'Z-Image' },
{ name: 'wan22-14b-t2v-instagirl.zip', expected: 'Wan 2.2' },
{ name: 'wan2.2-something.safetensors', expected: 'Wan 2.2' },
{ name: 'wan2.1-x.safetensors', expected: 'Wan 2.1' },
{ name: 'ltx2-squish.safetensors', expected: 'LTX 2' },
{ name: 'qwen-realcomic.zip', expected: 'Qwen' },
{
name: 'Qwen-Image-Edit-2511_Consistency.safetensors',
expected: 'Qwen Image Edit'
},
{ name: 'pony-50s_noir_movie.safetensors', expected: 'Pony' },
{
name: 'illustrious-retro_sci_fi_90_s_anime_style.safetensors',
expected: 'Illustrious'
},
{
name: 'hidream_o1_image_dev_fp8_scaled.safetensors',
expected: 'HiDream O1'
},
{ name: 'hidream-i1-bf16.safetensors', expected: 'HiDream I1' },
{ name: 'Chroma1-HD-fp8mixed.safetensors', expected: 'Chroma1 HD' },
{
name: 'chroma-radiance-x0.safetensors',
expected: 'Chroma1 Radiance'
},
{ name: 'something-unrelated.bin', expected: null }
])('infers $name -> $expected', ({ name, expected }) => {
expect(inferBaseModelFromText(name)).toBe(expected)
})
})
describe('refineBaseModelLabels', () => {
it('promotes a generic family-root label to a versioned variant from filename', () => {
expect(
refineBaseModelLabels(
['LTX Video'],
['LTX_2.3_Crisp_Enhance_Style.safetensors']
)
).toEqual(['LTX 2.3'])
})
it('replaces a non-canonical metadata label with the canonical inferred one', () => {
expect(
refineBaseModelLabels(['LTXV2'], ['ltxv23-dispatch_style.safetensors'])
).toEqual(['LTX 2.3'])
})
it('replaces a non-canonical "Flux.2 Klein 9B" with the canonical "Flux.2 Klein"', () => {
expect(
refineBaseModelLabels(
['Flux.2 Klein 9B'],
['flux-2-klein-9b-something.safetensors']
)
).toEqual(['Flux.2 Klein'])
})
it('keeps a specific label when filename only matches the family root', () => {
expect(
refineBaseModelLabels(['LTX 2.3'], ['something-ltx-tagged.safetensors'])
).toEqual(['LTX 2.3'])
})
it('does not touch labels from a different family', () => {
expect(
refineBaseModelLabels(['SDXL'], ['ltx_2.3_lora.safetensors'])
).toEqual(['SDXL'])
})
it('returns empty when input is empty', () => {
expect(refineBaseModelLabels([], ['anything.safetensors'])).toEqual([])
})
})

View File

@@ -0,0 +1,210 @@
/**
* Filename-based base-model inference for assets that lack both a
* `metadata.base_model` field and a [[BASE_MODEL_OVERRIDES]] entry — typically
* Civitai-sourced LoRAs with no HuggingFace repo. The pattern set mirrors the
* Python scraper's canonical rules so a `flux1-…` LoRA, a `zimage-…` LoRA, etc.
* land in the right bucket without manual tagging.
*
* Underscores are normalised to hyphens before matching because `\b` treats
* `_` as a word char and would otherwise miss `qwen-image_lora`.
*/
const CANONICAL_RULES: ReadonlyArray<
readonly [label: string, pattern: RegExp]
> = [
// Flux family — longest match first
['Flux.2 Klein', /\bflux[-.\s]?2[-.\s]?klein\b/i],
['Flux.2 dev', /\bflux[-.\s]?2\b/i],
['Flux.1 Krea', /\bflux[-.\s]?1?[-.\s]?krea\b/i],
['Flux.1 Kontext', /\bflux[-.\s]?1?[-.\s]?kontext\b/i],
['Flux.1 Redux', /\bflux[-.\s]?1?[-.\s]?redux\b/i],
['Flux.1 Schnell', /\bflux[-.\s]?1?[-.\s]?schnell\b/i],
['Flux.1 dev', /\bflux[-.\s]?1\b/i],
['Flux.1 dev', /\bflux\b/i],
// Stable Diffusion family — require sd/stable_diffusion prefix
['SDXL', /\bsd[-.\s]?xl\b|\bstable[-.\s]?diffusion[-.\s]?xl\b/i],
['SD 3.5', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?3[-.\s]?\.?5\b/i],
['SD 3', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?3\b/i],
['SD 2.1', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?2[-.\s]?\.?1\b/i],
['SD 2', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?2\b/i],
['SD 1.5', /\b(?:sd|stable[-.\s]?diffusion)[-.\s]?v?1[-.\s]?\.?5\b/i],
// Wan
['Wan 2.2', /\bwan[-.\s]?2[-.\s]?\.?2\b/i],
['Wan 2.1', /\bwan[-.\s]?2[-.\s]?\.?1\b/i],
['Wan', /\bwan\b/i],
// Hunyuan
['HunyuanVideo 1.5', /\bhunyuan[-.\s]?video[-.\s]?1[-.\s]?\.?5\b/i],
['HunyuanVideo', /\bhunyuan[-.\s]?video\b/i],
['Hunyuan Image', /\bhunyuan[-.\s]?image\b/i],
['Hunyuan 3D', /\bhunyuan[-.\s]?3d\b/i],
// Qwen — Image/Edit before plain Qwen
['Qwen Image Edit', /\bqwen[-.\s]?image[-.\s]?edit\b/i],
['Qwen Image', /\bqwen[-.\s]?image\b/i],
['Qwen', /\bqwen\b/i],
// SDXL-derivative bases — community treats as their own family
['Pony', /\bpony\b/i],
['Illustrious', /\billustrious\b/i],
// Other diffusion families — variants before family root
['HiDream I1', /\bhi[-_.\s]?dream[-_.\s]?i1\b/i],
['HiDream O1', /\bhi[-_.\s]?dream[-_.\s]?o1\b/i],
['HiDream', /\bhi[-.\s]?dream\b/i],
['Chroma1 Radiance', /\bchroma\d*[-_.\s]?radiance\b/i],
['Chroma1 HD', /\bchroma\d*[-_.\s]?hd\b/i],
['Chroma', /\bchroma\d*\b/i],
// Captioner / VLM families — placed before LTX so LTXV-packaged
// captioner files (e.g. `ltxv_florence2_promptgen_…`) classify by their
// actual model family, not the packaging prefix.
['CogFlorence', /\bcog[-_.\s]?florence\b/i],
['Florence-2', /\bflorence[-_.\s]?2\b/i],
['JoyCaption', /\bjoy[-_.\s]?caption\d*\b/i],
['LLaVA', /\bllava\b/i],
['SmolVLM', /\bsmol[-_.\s]?vlm\b/i],
['SmolLM2', /\bsmol[-_.\s]?lm\d*\b/i],
['SuperPrompt', /\bsuper[-_.\s]?prompt\b/i],
// Voice / TTS — Chatterbox Turbo before bare Chatterbox
['Chatterbox Turbo', /\bchatterbox[-_.\s]?turbo\b/i],
['Chatterbox', /\bchatterbox\b/i],
// Depth — V2 before V1
['Depth Anything V2', /\bdepth[-_.\s]?anything[-_.\s]?v?2\b/i],
['Depth Anything', /\bdepth[-_.\s]?anything\b/i],
// Other utility / motion / upscale families
['SegFormer', /\bsegformer\b/i],
['LivePortrait', /\blive[-_.\s]?portrait\b/i],
['DynamiCrafter', /\bdynami[-_.\s]?crafter\b/i],
['SeedVR2', /\bseed[-_.\s]?vr\d*\b/i],
['FlashVSR', /\bflash[-_.\s]?vsr\b/i],
['MimicMotion', /\bmimic[-_.\s]?motion\b/i],
['LatentSync', /\blatent[-_.\s]?sync\b/i],
// Vision encoders — SigLIP before CLIP so CLIP-only matches don't swallow siglip-*
['SigLIP', /\bsiglip\b/i],
['CLIP-ViT', /\bclip[-_.\s]?vit\b/i],
['Llama 3.2', /\bllama[-_.\s]?3[-_.\s]?\.?2\b/i],
['LTX 2.3', /\bltx[-.\s]?v?2[-.\s]?\.?3\b/i],
['LTX 2', /\bltx[-.\s]?v?2\b/i],
['LTX Video', /\bltx\b/i],
// Upscalers / restoration
['UltraSharp', /\bultrasharp\b/i],
['Real-ESRGAN', /\breal[-_.\s]?esrgan\b/i],
// Depth / normal estimation
['Lotus', /\blotus\b/i],
// Matting / background
['ViTMatte', /\bvit[-_.\s]?matte\b/i],
['LayerDiffusion', /\blayer[-_.\s]?diffusion\b|\blayer[-_.\s]?xl\b/i],
// Motion / interpolation
['RIFE', /\brife\b/i],
// Detection / pose
['GroundingDINO', /\bgrounding[-_.\s]?dino\b/i],
['DWPose', /\bdwpose\b|\bdw[-_.\s]?ll[-_.\s]?ucoco\b/i],
['Face Parsing', /\bface[-_.\s]?parsing\b/i],
// Additional language models
['ChatGLM3', /\bchat[-_.\s]?glm\d*\b/i],
['Gemma', /\bgemma\d*\b/i],
['Cosmos', /\bcosmos\b/i],
['Mochi', /\bmochi\b/i],
['Stable Audio', /\bstable[-.\s]?audio\b/i],
['AuraFlow', /\bauraflow\b/i],
['PixArt', /\bpixart\b/i],
['Kandinsky', /\bkandinsky\b/i],
['Playground', /\bplayground\b/i],
['Kolors', /\bkolors\b/i],
['Z-Image', /\bz[-_.\s]?image(?:[-_.\s]?turbo)?\b/i],
['Lumina', /\blumina\b/i],
['CogVideo', /\bcogvideo\b/i],
['AnimateDiff', /\banimatediff\b/i],
['ERNIE', /\bernie\b/i],
['Omnigen', /\bomnigen\d*\b/i],
['Ovis', /\bovis\b/i],
['ACE-Step', /\bace[-.\s]?step\b/i],
['HuMo', /\bhumo\b/i],
['LongCat', /\blongcat\b/i],
['Trellis', /\btrellis\b/i],
['USO', /\buso\b/i],
['OneReward', /\bone[-.\s]?reward\b/i],
['MoGe', /\bmoge\b/i],
['UltraShape', /\bultrashape\b/i],
['NewBie', /\bnewbie\b/i],
['PixelDiT', /\bpixel[-.\s]?dit\b/i],
['SAM 3D', /\bsam[-.\s]?3d\b/i],
['SAM 3', /\bsam[-.\s]?3(?!d)\b/i],
['SAM 2', /\bsam[-.\s]?2\b/i],
['SAM', /\bsam\b/i],
['BiRefNet', /\bbirefnet\b/i]
] as const
export function inferBaseModelFromText(text: string): string | null {
if (!text) return null
// Underscores are word chars to regex \b — swap to hyphens so things like
// "Qwen-Image_ComfyUI" or "flux1-foo" match cleanly.
const normalized = text.replace(/_/g, '-')
for (const [label, pattern] of CANONICAL_RULES) {
if (pattern.test(normalized)) return label
}
return null
}
const CANONICAL_LABELS: ReadonlySet<string> = new Set(
CANONICAL_RULES.map(([label]) => label)
)
/**
* Family-prefix rules. Maps labels (canonical and common non-canonical
* variants like `LTXV2`) onto a family bucket so refinement can spot when a
* filename suggests a more specific variant of the same family.
*/
const FAMILY_PREFIX_RULES: ReadonlyArray<readonly [RegExp, string]> = [
[/^(?:ltxv|ltx)/i, 'ltx'],
[/^(?:sdxl|sd|stable[-.\s]?diffusion)/i, 'sd'],
[/^flux/i, 'flux'],
[/^wan/i, 'wan'],
[/^hunyuan/i, 'hunyuan'],
[/^qwen/i, 'qwen'],
[/^z[-_.\s]?image/i, 'zimage'],
[/^hi[-_.\s]?dream/i, 'hidream'],
[/^sam/i, 'sam']
] as const
function familyOf(label: string): string {
for (const [pattern, family] of FAMILY_PREFIX_RULES) {
if (pattern.test(label)) return family
}
return label.toLowerCase().match(/^[a-z]+/)?.[0] ?? label.toLowerCase()
}
/**
* Refines metadata-derived base-model labels using filename inference. When
* the filename suggests a more specific variant of the same family — e.g.
* `LTX_2.3_…` whose HuggingFace card says only `Lightricks/LTX-Video` —
* promote to the specific variant.
*
* Rules per existing label:
* 1. If a filename-inferred label shares its family AND the existing label
* is non-canonical, replace with the canonical inferred label.
* 2. If both are canonical and same family, prefer the one with a version
* digit when the other has none.
*/
export function refineBaseModelLabels(
labels: readonly string[],
filenameSources: readonly string[]
): string[] {
if (labels.length === 0) return [...labels]
const inferences = filenameSources
.map((s) => inferBaseModelFromText(s))
.filter((x): x is string => Boolean(x))
if (inferences.length === 0) return [...labels]
return labels.map((existing) => {
const family = familyOf(existing)
for (const inferred of inferences) {
if (familyOf(inferred) !== family) continue
if (inferred === existing) return existing
const existingCanonical = CANONICAL_LABELS.has(existing)
const inferredCanonical = CANONICAL_LABELS.has(inferred)
if (!existingCanonical && inferredCanonical) return inferred
if (existingCanonical && inferredCanonical) {
const inferredHasDigit = /\d/.test(inferred)
const existingHasDigit = /\d/.test(existing)
if (inferredHasDigit && !existingHasDigit) return inferred
}
}
return existing
})
}

View File

@@ -0,0 +1,342 @@
/**
* Maps HuggingFace repo ids to the compatible base model(s) for any asset
* sourced from that repo. Used as a fallback when the asset itself doesn't
* carry a [[base_model]] field in its metadata.
*
* Generated one-shot from temp/scripts/scrape-base-models.py + emit-base-model-overrides.mjs
* by scraping HuggingFace cardData / tags / READMEs for every unique repo_id
* in the cloud asset list. Hand-edit entries that look wrong — the regenerator
* is destructive.
*
* Repos without a confident match are intentionally omitted; the UI falls
* back to an "Unknown base model" bucket for those.
*/
const BASE_MODEL_OVERRIDES: Readonly<Record<string, readonly string[]>> =
Object.freeze({
'100percentrobot/LTX-2.3-Audio-Reactive-LORA': ['LTX 2.3'],
'1038lab/sam3': ['SAM 3'],
'AInVFX/SeedVR2_comfyUI': ['SeedVR2'],
'alibaba-pai/Qwen-Image-2512-Fun-Controlnet-Union': ['Qwen Image'],
'alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union': ['Z-Image'],
'alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union-2.1': ['Z-Image'],
'Alissonerdx/BFS-Best-Face-Swap-Video': ['LTX 2.3'],
'Alissonerdx/LTX-LoRAs': ['LTX 2.3'],
'alvdansen/illustration-1.0-qwen-image': ['Qwen Image'],
'AviadDahan/ID-LoRA-CelebVHQ': ['LTX Video'],
'AviadDahan/ID-LoRA-TalkVid': ['LTX Video'],
'bionicman69/StarTrek_TNG_Style_LTX23': ['LTX 2.3'],
'black-forest-labs/FLUX.1-Canny-dev': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-Depth-dev-lora': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-dev': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-Fill-dev': ['Flux.1 dev'],
'black-forest-labs/FLUX.1-Kontext-dev': ['Flux.1 Kontext'],
'black-forest-labs/FLUX.1-Redux-dev': ['Flux.1 Redux'],
'black-forest-labs/FLUX.1-schnell': ['Flux.1 Schnell'],
'black-forest-labs/FLUX.2-klein-4b-fp8': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-klein-9B': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-klein-base-4b-fp8': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-klein-base-9b-fp8': ['Flux.2 Klein'],
'black-forest-labs/FLUX.2-small-decoder': ['Flux.2 dev'],
'ByteDance/LatentSync-1.6': ['LatentSync'],
'ByteDance/SDXL-Lightning': ['SDXL'],
'ByteZSzn/Flux.2-Turbo-ComfyUI': ['Flux.2 dev'],
'clayshoaf/Make-Wojak-2511': ['Qwen Image Edit'],
'Comfy-Org/ace_step_1.5_ComfyUI_files': ['ACE-Step'],
'Comfy-Org/ACE-Step_ComfyUI_repackaged': ['ACE-Step'],
'Comfy-Org/BiRefNet': ['BiRefNet'],
'Comfy-Org/Chroma1-HD_repackaged': ['Chroma1 HD'],
'Comfy-Org/Chroma1-Radiance_Repackaged': ['Chroma1 Radiance'],
'Comfy-Org/Cosmos_Predict2_repackaged': ['Cosmos'],
'Comfy-Org/ERNIE-Image': ['ERNIE'],
'Comfy-Org/FLUX.1-Krea-dev_ComfyUI': ['Flux.1 Krea'],
'Comfy-Org/flux1-dev': ['Flux.1 dev'],
'Comfy-Org/flux1-kontext-dev_ComfyUI': ['Flux.1 Kontext'],
'Comfy-Org/flux1-schnell': ['Flux.1 Schnell'],
'Comfy-Org/flux2-dev': ['Flux.2 dev'],
'Comfy-Org/flux2-klein-4B': ['Flux.2 Klein'],
'Comfy-Org/flux2-klein-9B': ['Flux.2 Klein'],
'Comfy-Org/gemma-4': ['Gemma'],
'Comfy-Org/HiDream-I1_ComfyUI': ['HiDream I1'],
'Comfy-Org/HiDream-O1-Image': ['HiDream O1'],
'Comfy-Org/HuMo_ComfyUI': ['HuMo'],
'Comfy-Org/hunyuan3D_2.0_repackaged': ['Hunyuan 3D'],
'Comfy-Org/hunyuan3D_2.1_repackaged': ['Hunyuan 3D'],
'Comfy-Org/HunyuanVideo_1.5_repackaged': ['HunyuanVideo 1.5'],
'Comfy-Org/HunyuanVideo_repackaged': ['HunyuanVideo'],
'Comfy-Org/LongCat-Image': ['LongCat'],
'Comfy-Org/lotus': ['Lotus'],
'Comfy-Org/ltx-2': ['LTX 2'],
'Comfy-Org/ltx-2.3': ['LTX 2.3'],
'Comfy-Org/mochi_preview_repackaged': ['Mochi'],
'Comfy-Org/MoGe': ['MoGe'],
'Comfy-Org/NewBie-image-Exp0.1_repackaged': ['NewBie'],
'Comfy-Org/Omnigen2_ComfyUI_repackaged': ['Omnigen'],
'Comfy-Org/OneReward_repackaged': ['OneReward'],
'Comfy-Org/Ovis-Image': ['Ovis'],
'Comfy-Org/Qwen-Image_ComfyUI': ['Qwen Image'],
'Comfy-Org/Qwen-Image-DiffSynth-ControlNets': ['Qwen Image'],
'Comfy-Org/Qwen-Image-Edit_ComfyUI': ['Qwen Image Edit'],
'Comfy-Org/Qwen-Image-InstantX-ControlNets': ['Qwen Image'],
'Comfy-Org/Qwen-Image-Layered_ComfyUI': ['Qwen Image'],
'Comfy-Org/Real-ESRGAN_repackaged': ['Real-ESRGAN'],
'Comfy-Org/sam3.1': ['SAM 3'],
'Comfy-Org/stable-audio-3': ['Stable Audio'],
'Comfy-Org/stable-audio-open-1.0_repackaged': ['Stable Audio'],
'Comfy-Org/stable-diffusion-3.5-fp8': ['SD 3.5'],
'Comfy-Org/stable-diffusion-v1-5-archive': ['SD 1.5'],
'Comfy-Org/USO_1.0_Repackaged': ['USO'],
'Comfy-Org/vae-text-encorder-for-flux-klein-9b': ['Flux.1 dev'],
'Comfy-Org/Wan_2.1_ComfyUI_repackaged': ['Wan 2.1'],
'Comfy-Org/Wan_2.2_ComfyUI_Repackaged': ['Wan 2.2'],
'Comfy-Org/z_image': ['Z-Image'],
'Comfy-Org/z_image_turbo': ['Z-Image'],
'comfyanonymous/cosmos_1.0_text_encoder_and_VAE_ComfyUI': ['Cosmos'],
'comfyanonymous/flux_text_encoders': ['Flux.1 dev'],
'Cseti/LTX2.3-22B_IC-LoRA-Cameraman_v1': ['LTX 2.3'],
'depth-anything/DA3-BASE': ['Depth Anything'],
'depth-anything/DA3-LARGE-1.1': ['Depth Anything'],
'depth-anything/DA3-SMALL': ['Depth Anything'],
'depth-anything/DA3METRIC-LARGE': ['Depth Anything'],
'depth-anything/DA3MONO-LARGE': ['Depth Anything'],
'depth-anything/Depth-Anything-V2-Large': ['Depth Anything V2'],
'DiffSynth-Studio/Qwen-Image-Layered-Control': ['Qwen Image'],
'DoctorDiffusion/LTX-2.3-IC-LoRA-Colorizer': ['LTX 2.3'],
'duongve/NetaYume-Lumina-Image-2.0': ['Lumina'],
'dx8152/Flux2-Klein-9B-Consistency': ['Flux.2 Klein'],
'dx8152/Flux2-Klein-9B-Enhanced-Details': ['Flux.2 Klein'],
'dx8152/Qwen-Edit-2509-Light-Migration': ['Qwen Image Edit'],
'dx8152/Qwen-Edit-2509-Multiple-angles': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-Fusion': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-Light_restoration': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-Relight': ['Qwen Image Edit'],
'dx8152/Qwen-Image-Edit-2509-White_to_Scene': ['Qwen Image Edit'],
'enigmatic/gummycandy_qwen': ['Qwen'],
'EQUES/qwen-image-edit-2511-lineart-interpolation': ['Qwen Image Edit'],
'fal/flux-2-klein-4B-background-remove-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4B-object-remove-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4B-outpaint-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4b-spritesheet-lora': ['Flux.2 Klein'],
'fal/flux-2-klein-4B-zoom-lora': ['Flux.2 Klein'],
'fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA': ['Qwen Image Edit'],
'fal/virtual-tryoff-lora': ['Flux.2 Klein'],
'gokaygokay/Florence-2-Flux': ['Florence-2'],
'gokaygokay/Florence-2-Flux-Large': ['Florence-2'],
'gokaygokay/Florence-2-SD3-Captioner': ['SD 3'],
'google/siglip-so400m-patch14-384': ['SigLIP'],
'guoyww/animatediff': ['AnimateDiff'],
'hr16/DWPose-TorchScript-BatchSize5': ['DWPose'],
'hr16/UnJIT-DWPose': ['DWPose'],
'HuggingFaceM4/Florence-2-DocVQA': ['Florence-2'],
'HuggingFaceTB/SmolLM2-1.7B-Instruct': ['SmolLM2'],
'HuggingFaceTB/SmolLM2-135M-Instruct': ['SmolLM2'],
'HuggingFaceTB/SmolLM2-360M-Instruct': ['SmolLM2'],
'HuggingFaceTB/SmolVLM-Instruct': ['SmolLM2', 'SigLIP'],
'hustvl/vitmatte-base-composition-1k': ['ViTMatte'],
'hustvl/vitmatte-small-composition-1k': ['ViTMatte'],
'infinith/UltraShape': ['Hunyuan 3D'],
'jetjodh/sam-3d-body-dinov3': ['SAM 3D'],
'jetjodh/sam-3d-objects': ['SAM 3D'],
'John6666/joy-caption-alpha-two-cli-mod': ['JoyCaption'],
'jonathandinu/face-parsing': ['Face Parsing'],
'joyfox/LTX2.3-ICEdit-Insight': ['LTX 2.3'],
'JunhaoZhuang/FlashVSR': ['FlashVSR'],
'JunhaoZhuang/FlashVSR-v1.1': ['FlashVSR'],
'kabachuha/ltx2-cakeify': ['LTX 2'],
'kabachuha/ltx2-eat': ['LTX 2'],
'kabachuha/ltx2-hydraulic-press': ['LTX 2'],
'kabachuha/ltx2-inflate-it': ['LTX 2'],
'kandinskylab/Kandinsky-5.0-I2V-Lite-5s': ['Kandinsky'],
'kandinskylab/Kandinsky-5.0-T2I-Lite': ['Kandinsky'],
'kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s': ['Kandinsky'],
'Kijai/ChatGLM3-safetensors': ['ChatGLM3'],
'Kijai/DepthAnythingV2-safetensors': ['Depth Anything V2'],
'Kijai/DynamiCrafter_pruned': ['DynamiCrafter'],
'Kijai/HunyuanVideo_comfy': ['HunyuanVideo'],
'Kijai/LivePortrait_safetensors': ['LivePortrait'],
'Kijai/llava-llama-3-8b-text-encoder-tokenizer': ['LLaVA'],
'Kijai/lotus-comfyui': ['Lotus'],
'Kijai/LTX2.3_comfy': ['LTX 2.3'],
'Kijai/LTXV2_comfy': ['LTX 2'],
'Kijai/MimicMotion_pruned': ['MimicMotion'],
'Kijai/sam2-safetensors': ['SAM 2'],
'Kijai/WanVideo_comfy': ['Wan 2.1'],
'Kijai/WanVideo_comfy_fp8_scaled': ['Wan 2.1'],
'Kim2091/UltraSharp': ['UltraSharp'],
'Kwai-Kolors/Kolors': ['Kolors'],
'Kwai-Kolors/Kolors-IP-Adapter-FaceID-Plus': ['Kolors'],
'Kwai-Kolors/Kolors-IP-Adapter-Plus': ['Kolors'],
'LayerDiffusion/layerdiffusion-v1': ['LayerDiffusion'],
'Lightricks/LTX-2': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Canny-Control': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Depth-Control': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Detailer': ['LTX 2'],
'Lightricks/LTX-2-19b-IC-LoRA-Pose-Control': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up': ['LTX 2'],
'Lightricks/LTX-2-19b-LoRA-Camera-Control-Static': ['LTX 2'],
'Lightricks/LTX-2.3': ['LTX 2.3'],
'Lightricks/LTX-2.3-22b-IC-LoRA-HDR': ['LTX 2.3'],
'Lightricks/LTX-2.3-22b-IC-LoRA-LipDub': ['LTX 2.3'],
'Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control': ['LTX 2.3'],
'Lightricks/LTX-2.3-fp8': ['LTX 2.3'],
'Lightricks/LTX-Video': ['LTX Video'],
'lightx2v/Qwen-Image-2512-Lightning': ['Qwen Image'],
'lightx2v/Qwen-Image-Edit-2511-Lightning': ['Qwen Image Edit'],
'lightx2v/Qwen-Image-Lightning': ['Qwen Image'],
'lightx2v/Wan2.2-Distill-Loras': ['Wan 2.2'],
'lilylilith/AnyPose': ['Qwen Image Edit'],
'lilylilith/QIE-2511-MP-AnyLight': ['Qwen Image Edit'],
'lkeab/hq-sam': ['SAM'],
'lodestones/Chroma': ['Chroma'],
'lodestones/Chroma1-HD': ['Chroma1 HD'],
'lovis93/crt-animation-terminal-ltx-2.3-lora': ['LTX Video'],
'lovis93/next-scene-qwen-image-lora-2509': ['Qwen Image Edit'],
'lrzjason/Anything2Real_2601': ['Qwen Image Edit'],
'lrzjason/ObjectRemovalFluxFill': ['Flux.1 dev'],
'lrzjason/QwenEdit-Anything2Real_Alpha': ['Qwen Image Edit'],
'lym00/Wan2.2_T2V_A14B_VACE-test': ['Wan 2.2'],
'MachineDelusions/LTX-2_Image2Video_Adapter_LoRa': ['LTX 2'],
'marduk191/rife': ['RIFE'],
'mattmdjaga/segformer_b2_clothes': ['SegFormer'],
'MiaoshouAI/Florence-2-base-PromptGen': ['Florence-2'],
'MiaoshouAI/Florence-2-base-PromptGen-v1.5': ['Florence-2'],
'MiaoshouAI/Florence-2-base-PromptGen-v2.0': ['Florence-2'],
'MiaoshouAI/Florence-2-large-PromptGen-v1.5': ['Florence-2'],
'MiaoshouAI/Florence-2-large-PromptGen-v2.0': ['Florence-2'],
'microsoft/Florence-2-base': ['Florence-2'],
'microsoft/Florence-2-base-ft': ['Florence-2'],
'microsoft/Florence-2-large': ['Florence-2'],
'microsoft/Florence-2-large-ft': ['Florence-2'],
'Nap/depth_anything_v2_vitg': ['Depth Anything V2'],
'Nebsh/LTX2_Animatediff_Lora': ['LTX 2'],
'Nebsh/LTX2_AtomicExplosion': ['LTX 2'],
'Nebsh/LTX2_Lora_Outfitcheck': ['LTX 2'],
'Nebsh/LTX2_Lora_TimelapseHuman': ['LTX 2'],
'Nebsh/LTX2_Outfitswitch': ['LTX 2'],
'numz/SeedVR2_comfyUI': ['SeedVR2'],
'OmerHagage/ltx2-greenscreen-avatar-ic-lora-vertical-v1': ['LTX 2.3'],
'openai/clip-vit-large-patch14': ['CLIP-ViT'],
'ostris/flux2_berthe_morisot': ['Flux.2 dev'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Deinterlace': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-MotionDeblur': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Outpaint': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-ReFocus': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Uncompress': ['LTX 2.3'],
'oumoumad/LTX-2.3-22b-IC-LoRA-Ungrade': ['LTX 2.3'],
'oumoumad/ltx-2.3-dearchive-lora': ['LTX 2.3'],
'oumoumad/LumiPic': ['Qwen Image Edit', 'Flux.2 Klein'],
'ovi054/QIE-2511-Color-Grade-Transfer-LoRA': ['Qwen Image Edit'],
'Owen777/UltraFlux-v1': ['Flux.1 dev'],
'peteromallet/Qwen-Image-Edit-InSubject': ['Qwen Image Edit'],
'Phr00t/WAN2.2-14B-Rapid-AllInOne': ['Wan 2.2'],
'PixArt-alpha/PixArt-Sigma-XL-2-1024-MS': ['PixArt'],
'prithivMLmods/QIE-2511-Extract-Outfit': ['Qwen Image Edit'],
'prithivMLmods/QIE-2511-Object-Remover-v2': ['Qwen Image Edit'],
'prithivMLmods/QIE-2511-Studio-DeLight': ['Qwen Image Edit'],
'prithivMLmods/QIE-2511-Zoom-Master': ['Qwen Image Edit'],
'prithivMLmods/Qwen-Image-Edit-2511-Midnight-Noir-Eyes-Spotlight': [
'Qwen Image Edit'
],
'prithivMLmods/Qwen-Image-Edit-2511-Noir-Comic-Book-Panel': [
'Qwen Image Edit'
],
'prithivMLmods/Qwen-Image-Edit-2511-Pixar-Inspired-3D': ['Qwen Image Edit'],
'prithivMLmods/Qwen-Image-Edit-2511-Ultra-Realistic-Portrait': [
'Qwen Image Edit'
],
'ProGamerGov/qwen-360-diffusion': ['Qwen Image'],
'Qwen/Qwen2.5-VL-3B-Instruct': ['Qwen'],
'Qwen/Qwen2.5-VL-7B-Instruct': ['Qwen'],
'Qwen/Qwen3-0.6B': ['Qwen'],
'Qwen/Qwen3-4B-Instruct-2507': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-0.6B-Base': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-1.7B-Base': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice': ['Qwen'],
'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign': ['Qwen'],
'Qwen/Qwen3-TTS-Tokenizer-12Hz': ['Qwen'],
'Qwen/Qwen3-VL-2B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-2B-Thinking': ['Qwen'],
'Qwen/Qwen3-VL-32B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-32B-Thinking': ['Qwen'],
'Qwen/Qwen3-VL-4B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-4B-Thinking': ['Qwen'],
'Qwen/Qwen3-VL-8B-Instruct': ['Qwen'],
'Qwen/Qwen3-VL-8B-Thinking': ['Qwen'],
'ResembleAI/chatterbox': ['Chatterbox'],
'ResembleAI/chatterbox-turbo': ['Chatterbox Turbo'],
'roborovski/superprompt-v1': ['SuperPrompt'],
'Ruicheng/moge-vitl': ['MoGe'],
'RunDiffusion/Juggernaut-XL-v9': ['SDXL'],
'sayeed99/segformer_b3_clothes': ['SegFormer'],
'sayeed99/segformer-b3-fashion': ['SegFormer'],
'Shakker-Labs/AWPortrait-QW': ['Qwen Image'],
'Shakker-Labs/AWPortrait-Z': ['Z-Image'],
'ShilongLiu/GroundingDINO': ['GroundingDINO'],
'stabilityai/sdxl-turbo': ['SDXL'],
'stabilityai/stable-audio-open-1.0': ['Stable Audio'],
'stabilityai/stable-diffusion-3.5-controlnets': ['SD 3.5'],
'stabilityai/stable-diffusion-xl-base-1.0': ['SDXL'],
'stabilityai/stable-diffusion-xl-refiner-1.0': ['SDXL'],
'StableDiffusionVN/Flux': ['Flux.1 dev'],
'systms/SYSTMS-ACTION-LoRA-Qwen-Image-Edit-2511': ['Qwen Image Edit'],
'systms/SYSTMS-FLW-IC-LORA-LTX-2.3': ['LTX Video'],
'systms/SYSTMS-INFL8-LoRA-Qwen-Image-Edit-2511': ['Qwen Image Edit'],
'systms/SYSTMS-TRNS-LoRA-Wan22': ['Wan 2.2'],
'TalmajM/LongCat-Image-Edit_ComfyUI_repackaged': ['LongCat'],
'tarn59/apply_texture_qwen_image_edit_2509': ['Qwen Image Edit'],
'tarn59/pixel_art_style_lora_z_image_turbo': ['Z-Image'],
'tencent/Hunyuan3D-2': ['Hunyuan 3D'],
'tencent/Hunyuan3D-2mv': ['Hunyuan 3D'],
'TencentARC/t2i-adapter-lineart-sdxl-1.0': ['SDXL'],
'TheBurgstall/ltx-2.3-googlyeyes-lora': ['LTX 2.3'],
'TheDenk/wan2.1-t2v-1.3b-controlnet-canny-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-1.3b-controlnet-depth-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-1.3b-controlnet-hed-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-14b-controlnet-canny-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-14b-controlnet-depth-v1': ['Wan 2.1'],
'TheDenk/wan2.1-t2v-14b-controlnet-hed-v1': ['Wan 2.1'],
'thwri/CogFlorence-2-Large-Freeze': ['Florence-2'],
'thwri/CogFlorence-2.1-Large': ['Florence-2'],
'unsloth/Llama-3.2-3B-Instruct': ['Llama 3.2'],
'vafipas663/Qwen-Edit-2509-Upscale-LoRA': ['Qwen Image Edit'],
'valiantcat/LTX-2.3-Transition-LORA': ['LTX 2.3'],
'valiantcat/LTX2-I2V-Smooth-LORA': ['LTX 2'],
'valiantcat/Qwen-Image-Edit-2509-Passionate-kiss': ['Qwen Image Edit'],
'valiantcat/Qwen-Image-Edit-2509-photous': ['Qwen Image Edit'],
'valiantcat/Qwen-Image-Edit-2511-Upscale2K': ['Qwen Image Edit'],
'vrgamedevgirl84/LTX_2.3_90s_Animation_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Cinematic_Sci-fi-Cyberpunk_Style_LoRa': [
'LTX Video'
],
'vrgamedevgirl84/LTX_2.3_Clay_Mation_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Crisp_Enhance_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Anime_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Painterly_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Puppet_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Fantasy_Realism_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Paper_Cut_Out_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Pixar_Toon_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Post_Apocalyptic_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Soft_Enhance_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX_2.3_Wild_West_Style_LoRa': ['LTX Video'],
'vrgamedevgirl84/LTX2.3_Cozy_Felt_Style_LoRa': ['LTX Video'],
'Wan-AI/Wan2.2-Animate-14B': ['Wan 2.2'],
'Wuli-art/Qwen-Image-2512-Turbo-LoRA-2-Steps': ['Qwen Image'],
'xtuner/llava-llama-3-8b-v1_1-transformers': ['LLaVA'],
'YaoJiefu/multiple-characters': ['Qwen Image Edit'],
'YxZhang/evf-sam': ['SAM'],
'YxZhang/evf-sam2': ['SAM 2'],
'yzd-v/DWPose': ['DWPose'],
'ZhengPeng7/BiRefNet': ['BiRefNet'],
'Zlikwid/LTX_2.3_Upscale_IC_Lora': ['LTX 2.3'],
'zooeyy/Qwen-Edit-2511_LightingRemap_Alpha0.2': ['Qwen Image Edit']
})
export function getBaseModelOverrides(repoId: string): readonly string[] {
return BASE_MODEL_OVERRIDES[repoId] ?? []
}

View File

@@ -0,0 +1,80 @@
/**
* Maps `Comfy-Org/<repo>` ids to the actual upstream provider.
*
* The Comfy-Org HuggingFace organisation hosts ~65 repackaged copies of
* third-party models. Showing "Comfy-Org" as the provider is misleading —
* users want to know the real upstream author (e.g. Black Forest Labs for
* FLUX, NVIDIA for Cosmos).
*
* Built one-shot from a scrape of every Comfy-Org HF README (see
* `temp/scripts/scrape-comfy-org-providers.py`). Entries omitted from this
* map fall back to the default `Comfy-Org` provider string — keep that
* behaviour for repos whose true upstream we couldn't identify with
* confidence.
*/
export const COMFY_ORG_PROVIDER_OVERRIDES: Readonly<Record<string, string>> =
Object.freeze({
'Comfy-Org/ACE-Step_ComfyUI_repackaged': 'ACE-Step',
'Comfy-Org/BiRefNet': 'ZhengPeng7',
'Comfy-Org/CLIP-ViT-H-14-laion2B-s32B-b79K_repackaged': 'laion',
'Comfy-Org/Chroma1-HD_repackaged': 'lodestones',
'Comfy-Org/Chroma1-Radiance_Repackaged': 'lodestones',
'Comfy-Org/Cosmos_Predict2_repackaged': 'nvidia',
'Comfy-Org/ERNIE-Image': 'baidu',
'Comfy-Org/FLUX.1-Krea-dev_ComfyUI': 'black-forest-labs',
'Comfy-Org/Flux1-Redux-Dev': 'black-forest-labs',
'Comfy-Org/HiDream-I1_ComfyUI': 'HiDream-ai',
'Comfy-Org/HiDream-O1-Image': 'HiDream-ai',
'Comfy-Org/HuMo_ComfyUI': 'bytedance-research',
'Comfy-Org/HunyuanImage_2.1_ComfyUI': 'tencent',
'Comfy-Org/HunyuanVideo_1.5_repackaged': 'tencent',
'Comfy-Org/HunyuanVideo_repackaged': 'tencent',
'Comfy-Org/Lens': 'microsoft',
'Comfy-Org/LongCat-Image': 'meituan-longcat',
'Comfy-Org/Lumina_Image_2.0_Repackaged': 'Alpha-VLLM',
'Comfy-Org/MoGe': 'microsoft',
'Comfy-Org/NewBie-image-Exp0.1_repackaged': 'NewBie-AI',
'Comfy-Org/OneReward_repackaged': 'bytedance-research',
'Comfy-Org/Omnigen2_ComfyUI_repackaged': 'OmniGen2',
'Comfy-Org/Ovis-Image': 'AIDC-AI',
'Comfy-Org/PixelDiT': 'nvidia',
'Comfy-Org/Qwen-Image-DiffSynth-ControlNets': 'DiffSynth-Studio',
'Comfy-Org/Qwen-Image-Edit_ComfyUI': 'dx8152',
'Comfy-Org/Qwen-Image-InstantX-ControlNets': 'InstantX',
'Comfy-Org/Qwen-Image-Layered_ComfyUI': 'Qwen',
'Comfy-Org/Qwen-Image_ComfyUI': 'Qwen',
'Comfy-Org/Qwen3.5': 'Qwen',
'Comfy-Org/Real-ESRGAN_repackaged': 'xinntao',
'Comfy-Org/T2I-Adapter_ComfyUI_Repackaged': 'TencentARC',
'Comfy-Org/TRELLIS.2': 'microsoft',
'Comfy-Org/USO_1.0_Repackaged': 'bytedance-research',
'Comfy-Org/Wan_2.1_ComfyUI_repackaged': 'Wan-AI',
'Comfy-Org/Wan_2.2_ComfyUI_Repackaged': 'Wan-AI',
'Comfy-Org/ace_step_1.5_ComfyUI_files': 'ACE-Step',
'Comfy-Org/flux1-dev': 'black-forest-labs',
'Comfy-Org/flux1-kontext-dev_ComfyUI': 'black-forest-labs',
'Comfy-Org/flux1-schnell': 'black-forest-labs',
'Comfy-Org/flux2-dev': 'black-forest-labs',
'Comfy-Org/frame_interpolation': 'google-research',
'Comfy-Org/gemma-4': 'google',
'Comfy-Org/hunyuan3D_2.0_repackaged': 'tencent',
'Comfy-Org/hunyuan3D_2.1_repackaged': 'tencent',
'Comfy-Org/lotus': 'jingheya',
'Comfy-Org/ltx-2': 'ovi054',
'Comfy-Org/ltx-2.3': 'Lightricks',
'Comfy-Org/mediapipe': 'google',
'Comfy-Org/mochi_preview_repackaged': 'genmo',
'Comfy-Org/sam3.1': 'facebook',
'Comfy-Org/sigclip_vision_384': 'google',
'Comfy-Org/stable-audio-3': 'stabilityai',
'Comfy-Org/stable-audio-open-1.0_repackaged': 'stabilityai',
'Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged':
'stabilityai',
'Comfy-Org/stable-diffusion-3.5-fp8': 'stabilityai',
'Comfy-Org/stable-diffusion-v1-5-archive': 'runwayml',
'Comfy-Org/stable_diffusion_2.1_repackaged': 'stabilityai',
'Comfy-Org/stable_diffusion_2.1_unclip_repackaged': 'stabilityai',
'Comfy-Org/void-model': 'netflix',
'Comfy-Org/z_image': 'Tongyi-MAI',
'Comfy-Org/z_image_turbo': 'Tongyi-MAI'
})

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { UNKNOWN_PROVIDER, getAssetProvider } from './modelGroups'
function makeAsset(metadata: Record<string, unknown>): AssetItem {
return { metadata } as unknown as AssetItem
}
describe('getAssetProvider', () => {
it('returns the override when the repo_id is a known Comfy-Org repackage', () => {
expect(
getAssetProvider(
makeAsset({ repo_id: 'Comfy-Org/Wan_2.2_ComfyUI_Repackaged' })
)
).toBe('Wan-AI')
expect(
getAssetProvider(makeAsset({ repo_id: 'Comfy-Org/flux1-dev' }))
).toBe('black-forest-labs')
})
it('falls back to the bare org for Comfy-Org repos without an override', () => {
expect(getAssetProvider(makeAsset({ repo_id: 'Comfy-Org/SDPose' }))).toBe(
'Comfy-Org'
)
})
it('returns the org prefix verbatim for non-Comfy-Org repos', () => {
expect(
getAssetProvider(makeAsset({ repo_id: 'black-forest-labs/FLUX.1-dev' }))
).toBe('black-forest-labs')
})
it('falls back to user_metadata.repo_id when metadata is missing', () => {
const asset = {
metadata: {},
user_metadata: { repo_id: 'Comfy-Org/TRELLIS.2' }
} as unknown as AssetItem
expect(getAssetProvider(asset)).toBe('microsoft')
})
it('returns the unknown sentinel when no repo_id is available', () => {
expect(getAssetProvider(makeAsset({}))).toBe(UNKNOWN_PROVIDER)
})
})

View File

@@ -0,0 +1,215 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { COMFY_ORG_PROVIDER_OVERRIDES } from './comfyOrgProviderOverrides'
export const PARTNER_NODES_GROUP_ID = 'partner-nodes'
export const UNKNOWN_PROVIDER = '—'
interface ModelGroupDef {
id: string
label: string
/** Raw category tags from the assets API that belong in this group. */
tags: readonly string[]
}
export const MODEL_GROUPS: readonly ModelGroupDef[] = [
{ id: 'loras', label: 'LoRAs', tags: ['loras'] },
{
id: 'diffusion',
label: 'Diffusion models',
tags: ['diffusion_models', 'checkpoints', 'diffusers', 'UltraShape']
},
{ id: 'language', label: 'Language models', tags: ['LLM', 'smol'] },
{
id: 'captioning',
label: 'Captioning / VLM',
tags: ['florence2', 'Joy_caption', 'superprompt-v1']
},
{
id: 'audio',
label: 'TTS & audio',
tags: ['qwen-tts', 'chatterbox', 'audio_encoders']
},
{
id: 'encoders',
label: 'Encoders',
tags: ['text_encoders', 'clip', 'clip_vision']
},
{
id: 'conditioning',
label: 'Conditioning',
tags: [
'controlnet',
'ipadapter',
'gligen',
'style_models',
'model_patches',
'inpaint'
]
},
{
id: 'segmentation',
label: 'Segmentation',
tags: [
'sams',
'sam2',
'sam3',
'sam3d',
'sam3dbody',
'EVF-SAM',
'segformer_b3_fashion',
'segformer_b3_clothes',
'segformer_b2_clothes',
'face_parsing'
]
},
{
id: 'video',
label: 'Video & motion',
tags: [
'CogVideo',
'liveportrait',
'mimicmotion',
'latentsync',
'animatediff_models',
'animatediff_motion_lora'
]
},
{
id: 'upscale',
label: 'Upscale / restore / interpolate',
tags: [
'upscale_models',
'latent_upscale_models',
'FlashVSR',
'FlashVSR-v1.1',
'SEEDVR2',
'rife',
'film',
'frame_interpolation',
'interpolation',
'optical_flow',
'onnx',
'sharp'
]
},
{
id: 'background',
label: 'Background, matting & layers',
tags: [
'BiRefNet',
'BEN',
'transparent-background',
'lama',
'rmbg',
'background_removal',
'vitmatte',
'vitmatte-base-composition-1k',
'layerstyle',
'layer_model'
]
},
{ id: 'vae', label: 'VAEs', tags: ['vae', 'vae_approx'] },
{
id: 'depth',
label: 'Depth & geometry',
tags: ['depthanything', 'depthanything3', 'geometry_estimation']
},
{
id: 'detection',
label: 'Detection / pose',
tags: [
'yolo',
'dwpose',
'ultralytics',
'detection',
'mediapipe',
'grounding-dino',
'nlf'
]
},
{ id: PARTNER_NODES_GROUP_ID, label: 'Partner nodes', tags: [] }
] as const
const TAG_TO_GROUP_ID = (() => {
const map = new Map<string, string>()
for (const group of MODEL_GROUPS) {
for (const tag of group.tags) map.set(tag, group.id)
}
return map
})()
/**
* Maps a raw asset category tag (e.g. "loras", "sam3d") to a group id.
* Returns null if the tag is unmapped — caller should render a fallback
* section keyed on the raw tag so new categories surface immediately.
*/
export function groupIdForRawTag(rawTag: string): string | null {
return TAG_TO_GROUP_ID.get(rawTag) ?? null
}
/**
* Extracts the provider segment from a partner-node category string.
* Example: "api node/image/BFL" -> "BFL".
*/
export function formatPartnerProvider(category: string | undefined): string {
if (!category) return ''
const parts = category.split('/')
return parts[parts.length - 1] ?? ''
}
export function isPartnerNodeCategory(category: string | undefined): boolean {
if (!category) return false
return category.toLowerCase().startsWith('api node')
}
export function fallbackGroupLabel(rawTag: string): string {
return formatCategoryLabel(rawTag)
}
/**
* Compact display name for a row:
* - Drops anything before the first '/' (provider prefix like "microsoft/").
* - Replaces hyphens between non-space characters with spaces.
* "Florence-2-large" -> "Florence 2 large"
* - Hyphens with a space on either side (" - ") are preserved.
* - Replaces underscores with spaces ("t5gemma_b_b_ul2" -> "t5gemma b b ul2").
*/
export function formatRowDisplayName(raw: string): string {
const slashIdx = raw.indexOf('/')
const afterProvider = slashIdx >= 0 ? raw.slice(slashIdx + 1) : raw
return afterProvider.replace(/(?<=\S)-(?=\S)/g, ' ').replace(/_/g, ' ')
}
/**
* Returns the HuggingFace-style organisation prefix from an asset's repo_id
* (e.g. "Comfy-Org/stable-audio-3" -> "Comfy-Org"), or [[UNKNOWN_PROVIDER]] if
* no provider can be inferred.
*/
export function getAssetProvider(asset: AssetItem): string {
return (
resolveProvider(asset.metadata?.['repo_id']) ??
resolveProvider(asset.user_metadata?.['repo_id']) ??
resolveAuthorField(asset.metadata?.['author']) ??
resolveAuthorField(asset.user_metadata?.['author']) ??
UNKNOWN_PROVIDER
)
}
function resolveAuthorField(author: unknown): string | null {
if (typeof author !== 'string') return null
const trimmed = author.trim()
return trimmed.length > 0 ? trimmed : null
}
function resolveProvider(repoId: unknown): string | null {
if (typeof repoId !== 'string' || !repoId) return null
return COMFY_ORG_PROVIDER_OVERRIDES[repoId] ?? getRepoOrg(repoId)
}
function getRepoOrg(repoId: unknown): string | null {
if (typeof repoId !== 'string' || !repoId) return null
const org = repoId.split('/')[0]
return org && org.length > 0 ? org : null
}

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
firstNonModelsTag,
groupIdForAsset,
groupLabelForAsset,
looksLikeVae,
partnerKind,
rawTagTopLevel
} from './modelLibraryGrouping'
function makeAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'a1',
name: 'companion.safetensors',
tags: ['models'],
...overrides
}
}
describe('firstNonModelsTag', () => {
it('returns the first tag that is not the models tag', () => {
expect(firstNonModelsTag(makeAsset({ tags: ['models', 'loras'] }))).toBe(
'loras'
)
})
it('returns null when the only tag is the models tag', () => {
expect(firstNonModelsTag(makeAsset({ tags: ['models'] }))).toBeNull()
})
})
describe('rawTagTopLevel', () => {
it('takes the segment before the first slash', () => {
expect(rawTagTopLevel('CogVideo/VAE')).toBe('CogVideo')
expect(rawTagTopLevel('loras')).toBe('loras')
})
})
describe('partnerKind', () => {
it('extracts the modality segment of a partner category', () => {
expect(partnerKind('api node/image/BFL')).toBe('image')
})
it('returns empty string when absent', () => {
expect(partnerKind(undefined)).toBe('')
expect(partnerKind('api node')).toBe('')
})
})
describe('looksLikeVae', () => {
it('matches a "vae" path segment in the tag', () => {
expect(looksLikeVae(makeAsset(), 'CogVideo/VAE')).toBe(true)
expect(looksLikeVae(makeAsset(), 'foo/vae_approx')).toBe(true)
})
it('matches "vae" as a word in the filename', () => {
expect(
looksLikeVae(makeAsset({ name: 'model_vae_v1.safetensors' }), 'encoders')
).toBe(true)
})
it('does not match "vae" embedded inside another word', () => {
expect(
looksLikeVae(makeAsset({ name: 'levaeon.safetensors' }), 'encoders')
).toBe(false)
})
})
describe('groupIdForAsset', () => {
it('keeps cross-base file types (loras, vae, conditioning) in their bucket', () => {
expect(groupIdForAsset(makeAsset({ tags: ['models', 'loras'] }))).toBe(
'loras'
)
expect(groupIdForAsset(makeAsset({ tags: ['models', 'vae'] }))).toBe('vae')
expect(groupIdForAsset(makeAsset({ tags: ['models', 'controlnet'] }))).toBe(
'conditioning'
)
})
it('routes vae-looking assets to the vae bucket even when tagged otherwise', () => {
expect(
groupIdForAsset(makeAsset({ tags: ['models', 'CogVideo/VAE'] }))
).toBe('vae')
})
it('lets a base-model category override the file-type bucket', () => {
const asset = makeAsset({
tags: ['models', 'text_encoders'],
metadata: { base_model: 'SDXL' }
})
expect(groupIdForAsset(asset)).toBe('diffusion')
})
it('falls back to the tag-derived group when no base override applies', () => {
expect(
groupIdForAsset(makeAsset({ tags: ['models', 'text_encoders'] }))
).toBe('encoders')
})
it('returns null for an unmapped tag with no resolvable base', () => {
expect(
groupIdForAsset(makeAsset({ tags: ['models', 'totallyunknown'] }))
).toBeNull()
})
})
describe('groupLabelForAsset', () => {
it('uses the model group label when the asset maps to a known group', () => {
expect(groupLabelForAsset(makeAsset({ tags: ['models', 'loras'] }))).toBe(
'LoRAs'
)
})
it('falls back to a formatted label for an unmapped tag', () => {
expect(
groupLabelForAsset(makeAsset({ tags: ['models', 'totallyunknown'] }))
).toBe('Totallyunknown')
})
})

View File

@@ -0,0 +1,88 @@
import { getCategoryOverrideForBase } from '@/components/sidebar/tabs/cloudModelLibrary/baseModelCategoryOverrides'
import {
MODEL_GROUPS,
groupIdForRawTag
} from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import { MODELS_TAG } from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
export function firstNonModelsTag(asset: AssetItem): string | null {
for (const tag of asset.tags) {
if (tag && tag !== MODELS_TAG) return tag
}
return null
}
export function rawTagTopLevel(tag: string): string {
return tag.split('/')[0]
}
export function groupLabelForAsset(asset: AssetItem): string {
const groupId = groupIdForAsset(asset)
if (groupId) {
const group = MODEL_GROUPS.find((g) => g.id === groupId)
if (group) return group.label
}
const tag = firstNonModelsTag(asset)
return tag ? formatCategoryLabel(rawTagTopLevel(tag)) : ''
}
export function partnerKind(category: string | undefined): string {
if (!category) return ''
const parts = category.split('/')
return parts[1] ?? ''
}
export function groupIdForAsset(asset: AssetItem): string | null {
const tag = firstNonModelsTag(asset)
if (!tag) return null
const tagGroup = groupIdForRawTag(rawTagTopLevel(tag))
// Cross-base file-types stay in their type bucket. The Base-model sort
// axis still keeps each family's items grouped together within that bucket.
if (
tagGroup === 'loras' ||
tagGroup === 'vae' ||
tagGroup === 'conditioning'
) {
return tagGroup
}
// Filename-based VAE detection: any file with "vae" in any path segment of
// its tag, name, or filepath belongs in the VAE bucket — catches assets
// tagged generically (`latentsync/vae`, `CogVideo/VAE`, `SEEDVR2`) or named
// `*_vae_*` but tagged as something else.
if (looksLikeVae(asset, tag)) return 'vae'
// For everything else, let the resolved base model's primary category
// override the file-type-derived bucket — keeps a family's text encoders
// and checkpoints visible together rather than scattered.
const bases = getAssetBaseModels(asset)
for (const base of bases) {
const override = getCategoryOverrideForBase(base)
if (override) return override
}
return tagGroup
}
export function looksLikeVae(asset: AssetItem, tag: string): boolean {
// Any path segment of the tag containing "vae" (handles `latentsync/vae`,
// `CogVideo/VAE`, etc.)
for (const segment of tag.split('/')) {
if (/^vae(_approx)?$/i.test(segment)) return true
}
// "vae" appearing as a word in the filename / display name
const sources = [
asset.name,
typeof asset.metadata?.filename === 'string'
? asset.metadata.filename
: undefined,
typeof asset.metadata?.filepath === 'string'
? asset.metadata.filepath
: undefined
]
for (const source of sources) {
if (typeof source !== 'string') continue
if (/(?:^|[^a-zA-Z0-9])vae(?:[^a-zA-Z0-9]|$)/i.test(source)) return true
}
return false
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { buildProviderGroups } from './modelLibrarySort'
import type { SidebarItem } from './modelLibrarySort'
function assetItem(
name: string,
overrides: Partial<AssetItem> = {}
): SidebarItem {
return {
kind: 'asset',
asset: { id: name, name, tags: ['models'], ...overrides }
}
}
const names = (items: SidebarItem[]) =>
items.map((i) => (i.kind === 'asset' ? i.asset.name : i.nodeDef.name))
describe('buildProviderGroups — flat (non base-model) modes', () => {
const items = [assetItem('Zebra'), assetItem('apple'), assetItem('Mango')]
it('sorts a single group AZ for nameAsc (case-insensitive)', () => {
const [group] = buildProviderGroups(items, 'nameAsc', false)
expect(group.provider).toBe('')
expect(names(group.items)).toEqual(['apple', 'Mango', 'Zebra'])
})
it('reverses for nameDesc', () => {
const [group] = buildProviderGroups(items, 'nameDesc', false)
expect(names(group.items)).toEqual(['Zebra', 'Mango', 'apple'])
})
it('orders by timestamp for recent, newest first', () => {
const dated = [
assetItem('old', { created_at: '2020-01-01T00:00:00Z' }),
assetItem('new', { created_at: '2024-01-01T00:00:00Z' }),
assetItem('mid', { created_at: '2022-01-01T00:00:00Z' })
]
const [group] = buildProviderGroups(dated, 'recent', false)
expect(names(group.items)).toEqual(['new', 'mid', 'old'])
})
})
describe('buildProviderGroups — search active', () => {
it('preserves input order and does not re-sort', () => {
const items = [assetItem('Zebra'), assetItem('apple')]
const [group] = buildProviderGroups(items, 'nameAsc', true)
expect(group.provider).toBe('')
expect(names(group.items)).toEqual(['Zebra', 'apple'])
})
})
describe('buildProviderGroups — base-model grouping', () => {
it('buckets by base model with the unknown bucket anchored last', () => {
const items = [
assetItem('sdxl-model', { metadata: { base_model: 'SDXL' } }),
assetItem('sd15-model', { metadata: { base_model: 'SD 1.5' } }),
assetItem('no-base-model')
]
const groups = buildProviderGroups(items, 'baseModelAsc', false)
expect(groups.map((g) => g.provider)).toEqual(['SD 1.5', 'SDXL', '—'])
expect(names(groups[2].items)).toEqual(['no-base-model'])
})
it('reverses bucket order for baseModelDesc but keeps unknown last', () => {
const items = [
assetItem('sdxl-model', { metadata: { base_model: 'SDXL' } }),
assetItem('sd15-model', { metadata: { base_model: 'SD 1.5' } }),
assetItem('no-base-model')
]
const groups = buildProviderGroups(items, 'baseModelDesc', false)
expect(groups.map((g) => g.provider)).toEqual(['SDXL', 'SD 1.5', '—'])
})
})

View File

@@ -0,0 +1,124 @@
import { formatRowDisplayName } from '@/components/sidebar/tabs/cloudModelLibrary/modelGroups'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDisplayName
} from '@/platform/assets/utils/assetMetadataUtils'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type AssetEntry = { kind: 'asset'; asset: AssetItem }
type PartnerEntry = { kind: 'partner'; nodeDef: ComfyNodeDefImpl }
export type SidebarItem = AssetEntry | PartnerEntry
export type ProviderGroup = { provider: string; items: SidebarItem[] }
export type Section = {
id: string
label: string
providers: ProviderGroup[]
totalCount: number
}
export type SortMode =
| 'recent'
| 'oldest'
| 'nameAsc'
| 'nameDesc'
| 'baseModelAsc'
| 'baseModelDesc'
const UNKNOWN_BASE_MODEL_LABEL = '—'
function itemSortKey(item: SidebarItem): string {
return item.kind === 'asset'
? formatRowDisplayName(getAssetDisplayName(item.asset))
: (item.nodeDef.display_name ?? item.nodeDef.name)
}
function itemTimestamp(item: SidebarItem): number {
if (item.kind !== 'asset') return 0
const ts = item.asset.created_at ?? item.asset.updated_at
if (!ts) return 0
const parsed = Date.parse(ts)
return Number.isNaN(parsed) ? 0 : parsed
}
function compareByName(a: SidebarItem, b: SidebarItem): number {
return itemSortKey(a).localeCompare(itemSortKey(b), undefined, {
sensitivity: 'base'
})
}
function compareByMode(a: SidebarItem, b: SidebarItem, mode: SortMode): number {
switch (mode) {
case 'recent':
return itemTimestamp(b) - itemTimestamp(a) || compareByName(a, b)
case 'oldest':
return itemTimestamp(a) - itemTimestamp(b) || compareByName(a, b)
case 'nameDesc':
case 'baseModelDesc':
return -compareByName(a, b)
case 'nameAsc':
case 'baseModelAsc':
default:
return compareByName(a, b)
}
}
function isBaseModelMode(mode: SortMode): boolean {
return mode === 'baseModelAsc' || mode === 'baseModelDesc'
}
function itemBaseModels(item: SidebarItem): string[] {
if (item.kind === 'asset') return getAssetBaseModels(item.asset)
return []
}
export function buildProviderGroups(
items: SidebarItem[],
mode: SortMode,
isSearching: boolean
): ProviderGroup[] {
// When a search is active, preserve Fuse's relevance ranking instead of
// re-sorting by the user's chosen sort mode.
if (isSearching) {
return [{ provider: '', items: items.slice() }]
}
if (!isBaseModelMode(mode)) {
return [
{
provider: '',
items: items.slice().sort((a, b) => compareByMode(a, b, mode))
}
]
}
// Items with multiple compatible base models show under each. Items with
// no known base land in a trailing "—" bucket.
const buckets = new Map<string, SidebarItem[]>()
for (const item of items) {
const bases = itemBaseModels(item)
if (bases.length === 0) {
const list = buckets.get(UNKNOWN_BASE_MODEL_LABEL) ?? []
list.push(item)
buckets.set(UNKNOWN_BASE_MODEL_LABEL, list)
continue
}
for (const base of bases) {
const list = buckets.get(base) ?? []
list.push(item)
buckets.set(base, list)
}
}
const direction = mode === 'baseModelDesc' ? -1 : 1
const labels = Array.from(buckets.keys()).sort((a, b) => {
if (a === UNKNOWN_BASE_MODEL_LABEL && b !== UNKNOWN_BASE_MODEL_LABEL)
return 1
if (b === UNKNOWN_BASE_MODEL_LABEL && a !== UNKNOWN_BASE_MODEL_LABEL)
return -1
return direction * a.localeCompare(b, undefined, { sensitivity: 'base' })
})
return labels.map((label) => ({
provider: label,
items: (buckets.get(label) ?? []).slice().sort(compareByName)
}))
}

View File

@@ -0,0 +1,40 @@
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import { getCurrentInstance, h, render } from 'vue'
import NodePreview from '@/components/node/NodePreview.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
type DragPreviewArgs = Parameters<
NonNullable<Parameters<typeof draggable>[0]['onGenerateDragPreview']>
>[0]
/**
* Renders a [[NodePreview]] under the cursor while the row is being dragged.
* Returns an [[onGenerateDragPreview]] handler ready to pass to
* [[usePragmaticDraggable]]; if [[resolveNodeDef]] yields null the browser's
* default drag image is used.
*/
export function useNodePreviewDragImage(
resolveNodeDef: () => ComfyNodeDefV2 | null
) {
const appContext = getCurrentInstance()?.appContext ?? null
return function onGenerateDragPreview({
nativeSetDragImage
}: DragPreviewArgs) {
const nodeDef = resolveNodeDef()
if (!nodeDef) return
setCustomNativeDragPreview({
nativeSetDragImage,
render: ({ container }) => {
const vnode = h(NodePreview, { nodeDef, position: 'relative' })
if (appContext) vnode.appContext = appContext
render(vnode, container)
return () => {
render(null, container)
}
}
})
}
}

View File

@@ -0,0 +1,57 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetModelType } from '@/platform/assets/utils/assetMetadataUtils'
// Three-color gradient placeholders, one per category. Used in the model
// library hover popover when neither a native nor a curated thumbnail is
// available so the user still gets a visual cue tied to the model type.
type Palette = readonly [string, string, string]
const CATEGORY_PALETTES: Record<string, Palette> = {
loras: ['#ec4899', '#a855f7', '#6366f1'],
vae: ['#06b6d4', '#0891b2', '#0e7490'],
text_encoders: ['#f59e0b', '#dc2626', '#7c2d12'],
diffusion_models: ['#10b981', '#059669', '#064e3b'],
checkpoints: ['#8b5cf6', '#7c3aed', '#5b21b6'],
controlnet: ['#0ea5e9', '#0284c7', '#075985'],
ipadapter: ['#f43f5e', '#e11d48', '#9f1239'],
upscale_models: ['#eab308', '#ca8a04', '#854d0e'],
depthanything: ['#84cc16', '#65a30d', '#365314'],
florence2: ['#a78bfa', '#7c3aed', '#4c1d95'],
sam3d: ['#34d399', '#14b8a6', '#0f766e'],
geometry_estimation: ['#fb923c', '#f97316', '#9a3412'],
model_patches: ['#94a3b8', '#64748b', '#334155'],
smol: ['#fde047', '#facc15', '#a16207'],
LLM: ['#f97316', '#ea580c', '#7c2d12']
}
function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
function paletteFromHash(category: string): Palette {
const base = hashString(category) % 360
return [
`hsl(${base}, 70%, 55%)`,
`hsl(${(base + 40) % 360}, 65%, 45%)`,
`hsl(${(base + 80) % 360}, 60%, 35%)`
]
}
function topLevel(category: string): string {
return category.split('/')[0]
}
export function placeholderGradientForCategory(category: string): string {
const key = topLevel(category)
const palette = CATEGORY_PALETTES[key] ?? paletteFromHash(key)
return `linear-gradient(135deg, ${palette[0]}, ${palette[1]}, ${palette[2]})`
}
export function placeholderCategoryForAsset(asset: AssetItem): string {
return getAssetModelType(asset) ?? 'unknown'
}

View File

@@ -0,0 +1,18 @@
import { markRaw } from 'vue'
import CloudModelLibrarySidebarTab from '@/components/sidebar/tabs/cloudModelLibrary/CloudModelLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const CLOUD_MODEL_LIBRARY_TAB_ID = 'model-library'
export const useCloudModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: CLOUD_MODEL_LIBRARY_TAB_ID,
icon: 'icon-[comfy--ai-model]',
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(CloudModelLibrarySidebarTab),
type: 'vue'
}
}

View File

@@ -0,0 +1,117 @@
import { computed, ref, watch } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { ComfyModelDef } from '@/stores/modelStore'
import { useModelStore } from '@/stores/modelStore'
// Local "Model Library" data source for desktop/localhost distributions. Wraps
// the legacy useModelStore (which lists folders via /models and files via
// /models/{folder}) and adapts each ComfyModelDef into the AssetItem shape so
// the existing cloud library UI can render local files without forking.
//
// AssetItem shape mapping:
// id local:<directory>/<file_name> (stable, collision-safe)
// name normalized file_name (path within folder, e.g. sdxl/foo)
// display_name leaf filename without .safetensors
// tags ['models', <directory>] (drives category grouping)
// metadata { filepath, directory, path_index } (used downstream)
//
// Cloud-only fields like preview_url, base_model, repo_id stay undefined until
// the enrichment layers (sibling image / safetensors header / Civitai) land.
function adaptModelToAsset(model: ComfyModelDef): AssetItem {
const filepath = `${model.directory}/${model.normalized_file_name}`
const tags = ['models', model.directory]
for (const t of model.tags) {
if (t && !tags.includes(t)) tags.push(t)
}
const id = `local:${filepath}`
return {
id,
name: model.normalized_file_name,
display_name:
model.title?.trim() ||
model.simplified_file_name ||
model.normalized_file_name,
tags,
is_immutable: false,
metadata: {
filepath,
directory: model.directory,
path_index: model.path_index,
base_model: model.architecture_id || undefined,
author: model.author || undefined,
description: model.description || undefined,
trigger_phrase: model.trigger_phrase || undefined,
resolution: model.resolution || undefined,
usage_hint: model.usage_hint || undefined,
preview_image: model.image || undefined
}
}
}
export interface LocalModelLibrarySource {
assets: ComputedRef<AssetItem[]>
isLoading: Ref<boolean>
refresh: () => Promise<void>
}
// Module-level shared state so calling useLocalModelLibrarySource() from
// multiple sites (sidebar tab, widget picker, etc.) shares one fetch lifecycle
// instead of clobbering useModelStore's folder map on each call.
let cached: LocalModelLibrarySource | null = null
export function useLocalModelLibrarySource(): LocalModelLibrarySource {
if (cached) return cached
const modelStore = useModelStore()
const isLoading = ref(false)
// ComfyModelDef fields are mutated on plain class instances after load() —
// Vue can't reliably observe that. Bumping enrichmentTick after each load
// forces the assets computed to re-read the (now-populated) fields.
const enrichmentTick = ref(0)
let inflight: Promise<void> | null = null
async function refresh(): Promise<void> {
if (inflight) return inflight
isLoading.value = true
inflight = (async () => {
try {
await modelStore.loadModelFolders()
await modelStore.loadModels()
} finally {
isLoading.value = false
inflight = null
}
})()
return inflight
}
void refresh()
const assets = computed<AssetItem[]>(() => {
// Touch the tick so this recomputes when new metadata lands.
void enrichmentTick.value
return modelStore.models.map(adaptModelToAsset)
})
// Trigger per-file safetensors metadata loading lazily. After each load
// resolves we bump enrichmentTick so the computed picks up the new fields.
watch(
() => modelStore.models.length,
() => {
for (const m of modelStore.models) {
if (!m.has_loaded_metadata && !m.is_load_requested) {
void m.load().then(() => {
enrichmentTick.value++
})
}
}
},
{ immediate: true }
)
cached = { assets, isLoading, refresh }
return cached
}

View File

@@ -0,0 +1,123 @@
import { useEventListener, useResizeObserver } from '@vueuse/core'
import type { CSSProperties, Ref } from 'vue'
import { nextTick, onBeforeUnmount, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
// Single shared hover popover, owned by the sidebar tab. Leaves emit
// `hover-change` with their row rect; we position the popover next to the
// row, swap content as the user moves between rows (no stacking), and
// support the row → popover mouse bridge with a short hide delay.
const HOVER_BRIDGE_DELAY_MS = 120
const HOVER_GAP_PX = 12
const HOVER_VIEWPORT_MARGIN_PX = 8
type HoveredItem =
| { kind: 'asset'; asset: AssetItem; rect: DOMRect }
| { kind: 'partner'; nodeDef: ComfyNodeDefImpl; rect: DOMRect }
export function useModelLibraryHoverPopover(
hoverPopoverRef: Ref<HTMLElement | null>
) {
const hoveredItem = ref<HoveredItem | null>(null)
const hoverPopoverStyle = ref<CSSProperties>({ top: '0px', left: '0px' })
let hoverHideTimer: ReturnType<typeof setTimeout> | null = null
function cancelHoverHide() {
if (hoverHideTimer !== null) {
clearTimeout(hoverHideTimer)
hoverHideTimer = null
}
}
function scheduleHoverHide() {
cancelHoverHide()
hoverHideTimer = setTimeout(() => {
hoveredItem.value = null
hoverHideTimer = null
}, HOVER_BRIDGE_DELAY_MS)
}
async function updateHoverPopoverPosition() {
const rect = hoveredItem.value?.rect
if (!rect) return
await nextTick()
const el = hoverPopoverRef.value
const popoverHeight = el?.offsetHeight ?? 240
const minTop = HOVER_VIEWPORT_MARGIN_PX
const maxTop = Math.max(
minTop,
window.innerHeight - popoverHeight - HOVER_VIEWPORT_MARGIN_PX
)
const top = Math.max(minTop, Math.min(rect.top, maxTop))
hoverPopoverStyle.value = {
top: `${top}px`,
left: `${rect.right + HOVER_GAP_PX}px`
}
}
function handleAssetHoverChange(
payload: { asset: AssetItem; rect: DOMRect } | { asset: null }
) {
if (payload.asset) {
cancelHoverHide()
hoveredItem.value = {
kind: 'asset',
asset: payload.asset,
rect: payload.rect
}
void updateHoverPopoverPosition()
} else {
scheduleHoverHide()
}
}
function handlePartnerHoverChange(
payload: { nodeDef: ComfyNodeDefImpl; rect: DOMRect } | { nodeDef: null }
) {
if (payload.nodeDef) {
cancelHoverHide()
hoveredItem.value = {
kind: 'partner',
nodeDef: payload.nodeDef,
rect: payload.rect
}
void updateHoverPopoverPosition()
} else {
scheduleHoverHide()
}
}
function handlePopoverEnter() {
cancelHoverHide()
}
function handlePopoverLeave() {
scheduleHoverHide()
}
useResizeObserver(hoverPopoverRef, () => {
void updateHoverPopoverPosition()
})
useEventListener(window, 'resize', () => {
void updateHoverPopoverPosition()
})
useEventListener(
window,
'scroll',
() => {
void updateHoverPopoverPosition()
},
true
)
onBeforeUnmount(() => {
cancelHoverHide()
})
return {
hoveredItem,
hoverPopoverStyle,
handleAssetHoverChange,
handlePartnerHoverChange,
handlePopoverEnter,
handlePopoverLeave
}
}

View File

@@ -0,0 +1,42 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
export const LEAF_ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-grab items-center gap-2 overflow-hidden rounded-sm py-1.5 pr-2 pl-8 outline-none select-none hover:bg-comfy-input'
export const LEAF_MENU_CONTENT_CLASS =
'z-9999 min-w-44 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md'
export const LEAF_MENU_ITEM_CLASS =
'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight'
// Shared row wiring for a Model Library leaf (asset or partner node): the row
// ref, context-menu open state, and the mouseenter/leave bridge that drives the
// parent's shared hover popover via onShow(rect)/onHide().
export function useModelLibraryLeaf(options: {
onShow: (rect: DOMRect) => void
onHide: () => void
}) {
const rowRef = ref<HTMLElement | null>(null)
const isContextMenuOpen = ref(false)
// Opening the context menu dismisses the hover popover so the two don't stack.
watch(isContextMenuOpen, (open) => {
if (open) options.onHide()
})
const handleMouseEnter = () => {
const rect = rowRef.value?.getBoundingClientRect()
if (rect) options.onShow(rect)
}
const handleMouseLeave = () => options.onHide()
onMounted(() => {
rowRef.value?.addEventListener('mouseenter', handleMouseEnter)
rowRef.value?.addEventListener('mouseleave', handleMouseLeave)
})
onBeforeUnmount(() => {
rowRef.value?.removeEventListener('mouseenter', handleMouseEnter)
rowRef.value?.removeEventListener('mouseleave', handleMouseLeave)
options.onHide()
})
return { rowRef, isContextMenuOpen }
}

View File

@@ -1,28 +0,0 @@
import { markRaw } from 'vue'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'model-library',
icon: 'icon-[comfy--ai-model]',
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {
if (isDesktop) {
const electronDownloadStore = useElectronDownloadStore()
if (electronDownloadStore.inProgressDownloads.length > 0) {
return electronDownloadStore.inProgressDownloads.length.toString()
}
}
return null
}
}
}

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