Compare commits

..

14 Commits

Author SHA1 Message Date
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
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
139 changed files with 8106 additions and 2952 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
)
})
})

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

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

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

@@ -4634,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',
@@ -4677,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,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,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

@@ -1,48 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "TEST_MISSING_PACK_NODE_A",
"pos": [48, 86],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_A",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
},
{
"id": 2,
"type": "TEST_MISSING_PACK_NODE_B",
"pos": [520, 86],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "TEST_MISSING_PACK_NODE_B",
"cnr_id": "test-missing-node-pack"
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -45,8 +45,6 @@ export const TestIds = {
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
missingNodePackExpand: 'missing-node-pack-expand',
missingNodePackCount: 'missing-node-pack-count',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',

View File

@@ -4,7 +4,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
@@ -12,39 +12,27 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
})
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should show unknown pack node rows by default', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
test('Should show subgraph missing node rows by default', async ({
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -55,72 +43,66 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should locate missing node from the row label', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeHidden()
})
test('Should toggle grouped pack nodes from chevron and title', async ({
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_same_pack'
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
const packTitle = missingNodeCard.getByRole('button', {
name: 'test-missing-node-pack'
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
})
const expandButton = missingNodeCard.getByTestId(
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()
await expect(
missingNodeCard.getByTestId(TestIds.dialogs.missingNodePackCount)
).toHaveText('2')
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await expandButton.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await packTitle.click()
await expect(firstNode).toBeHidden()
await expect(secondNode).toBeHidden()
await packTitle.click()
await expect(firstNode).toBeVisible()
await expect(secondNode).toBeVisible()
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
})
})

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

@@ -71,11 +71,12 @@ vi.mock('./MissingPackGroupRow.vue', () => ({
name: 'MissingPackGroupRow',
template: `<div class="pack-row" data-testid="pack-row"
:data-show-info-button="String(showInfoButton)"
:data-show-node-id-badge="String(showNodeIdBadge)"
>
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
</div>`,
props: ['group', 'showInfoButton'],
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
}))
@@ -121,6 +122,7 @@ function makePackGroups(count = 2): MissingPackGroup[] {
function renderCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
@@ -128,6 +130,7 @@ function renderCard(
const result = render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
@@ -166,10 +169,12 @@ describe('MissingNodeCard', () => {
it('passes props correctly to MissingPackGroupRow children', () => {
renderCard({
showInfoButton: true
showInfoButton: true,
showNodeIdBadge: true
})
const row = screen.getAllByTestId('pack-row')[0]
expect(row.getAttribute('data-show-info-button')).toBe('true')
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
})
})
@@ -251,6 +256,7 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onLocateNode
},
@@ -273,6 +279,7 @@ describe('MissingNodeCard', () => {
render(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
onOpenManagerInfo
},

View File

@@ -56,29 +56,27 @@
>
</template>
</i18n-t>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="secondary"
size="sm"
variant="primary"
:disabled="isRestarting"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
class="mt-2 h-9 w-full justify-center gap-2 text-sm font-semibold"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<i
v-else
aria-hidden="true"
@@ -107,8 +105,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const { showInfoButton, missingPackGroups } = defineProps<{
const { showInfoButton, showNodeIdBadge, missingPackGroups } = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()

View File

@@ -61,16 +61,16 @@ const i18n = createI18n({
messages: {
en: {
g: {
install: 'Install',
loading: 'Loading',
search: 'Search'
loading: 'Loading'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
@@ -100,6 +100,7 @@ function renderRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
const user = userEvent.setup()
@@ -109,6 +110,7 @@ function renderRow(
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
onLocateNode,
onOpenManagerInfo,
...props
@@ -116,6 +118,7 @@ function renderRow(
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
@@ -153,22 +156,9 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText(/Loading/)).toBeInTheDocument()
})
it('does not render header locate while pack metadata is resolving', () => {
renderRow({
group: makeGroup({
isResolving: true,
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders node count', () => {
renderRow()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
})
it('renders count of 5 for 5 nodeTypes', () => {
@@ -181,29 +171,38 @@ describe('MissingPackGroupRow', () => {
}))
})
})
expect(screen.getByText('5')).toBeInTheDocument()
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
})
})
describe('Node Type List', () => {
it('hides multiple nodeTypes behind the expand control by default', () => {
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
renderRow()
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
expect(screen.queryByText('MissingB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
})
it('shows unknown pack nodeTypes by default', () => {
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
it('expands when chevron is clicked', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
expect(screen.getByText('MissingB')).toBeInTheDocument()
})
it('renders all nodeTypes after expanding', async () => {
it('collapses when chevron is clicked again', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
})
})
describe('Node Type List', () => {
async function expand(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Expand' }))
}
it('renders all nodeTypes when expanded', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [
@@ -213,87 +212,40 @@ describe('MissingPackGroupRow', () => {
]
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
await expand(user)
expect(screen.getByText('NodeA')).toBeInTheDocument()
expect(screen.getByText('NodeB')).toBeInTheDocument()
expect(screen.getByText('NodeC')).toBeInTheDocument()
})
it('hides multiple nodeTypes again after collapsing', async () => {
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('MissingA')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { user } = renderRow({ showNodeIdBadge: true })
await expand(user)
expect(screen.getByText('#10')).toBeInTheDocument()
})
it('hides a single nodeType without an expand control', () => {
renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '1', isReplaceable: false }]
})
})
expect(screen.queryByText('OnlyNode')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Expand' })
).not.toBeInTheDocument()
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { user } = renderRow({ showNodeIdBadge: false })
await expand(user)
expect(screen.queryByText('#10')).not.toBeInTheDocument()
})
it('emits locateNode when the pack label is clicked for one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(screen.getByRole('button', { name: 'my-pack' }))
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('moves locate to the header when there is one nodeType', async () => {
const { user, onLocateNode } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'OnlyNode', nodeId: '100', isReplaceable: false }]
})
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
it('emits locateNode when expanded child Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
it('emits locateNode when Locate button is clicked', async () => {
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
await expand(user)
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('emits locateNode when node label is clicked', async () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'MissingA' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not show Locate for nodeType without nodeId', () => {
renderRow({
it('does not show Locate for nodeType without nodeId', async () => {
const { user } = renderRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(user)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
@@ -301,6 +253,7 @@ describe('MissingPackGroupRow', () => {
it('handles mixed nodeTypes with and without nodeId', async () => {
const { user } = renderRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
@@ -308,7 +261,7 @@ describe('MissingPackGroupRow', () => {
]
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
await expand(user)
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
@@ -321,25 +274,21 @@ describe('MissingPackGroupRow', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
renderRow()
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
renderRow({ group: makeGroup({ packId: null }) })
expect(
screen.queryByRole('button', { name: 'Install' })
).not.toBeInTheDocument()
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
})
it('shows Search when packId exists but pack not in registry', () => {
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
renderRow()
expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
})
it('shows "Installed" state when pack is installed', () => {
@@ -363,9 +312,7 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
renderRow()
expect(
screen.getByRole('button', { name: 'Install' })
).toBeInTheDocument()
expect(screen.getByText('Install node pack')).toBeInTheDocument()
})
it('calls installAllPacks when Install button is clicked', async () => {
@@ -373,7 +320,9 @@ describe('MissingPackGroupRow', () => {
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const { user } = renderRow()
await user.click(screen.getByRole('button', { name: 'Install' }))
await user.click(
screen.getByRole('button', { name: /Install node pack/ })
)
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
@@ -420,7 +369,7 @@ describe('MissingPackGroupRow', () => {
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
renderRow({ group: makeGroup({ nodeTypes: [] }) })
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
})
})
})

View File

@@ -1,221 +1,187 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div class="mb-2 flex w-full flex-col">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 w-full items-center">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="mr-1.5 icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<Button
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="unset"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
<i
v-if="isUnknownPack"
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes && !group.isResolving"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
:aria-expanded="expanded"
@click="toggleExpand"
>
{{ packDisplayName }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
:class="
cn(
packTextButtonClass,
isUnknownPack
? 'text-warning-background'
: 'text-base-foreground'
)
"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ packDisplayName }}
</button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{ packDisplayName }}
</span>
#{{ nodeType.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="showInfoButton && group.packId !== null"
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--info] size-4" />
<i class="icon-[lucide--locate] size-3" />
</Button>
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
</span>
</span>
<div v-if="showInstallAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: isPackInstalled
? t('rightSidePanel.missingNodePacks.installed')
: t('g.install')
}}
</span>
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="text-foreground mr-1 icon-[lucide--check] size-4 shrink-0"
/>
<i
v-else
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex w-full items-start py-1"
>
<div
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
class="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background p-2 opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
<div v-else-if="showSearchAction" class="ml-auto shrink-0">
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<span class="text-foreground min-w-0 truncate">
{{ t('g.search') }}
</span>
</Button>
</div>
<Button
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex w-full items-start py-1"
>
<Button
variant="secondary"
size="md"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<li
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
:class="
cn(
packTextButtonClass,
'text-muted-foreground hover:text-base-foreground'
)
"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
<i class="text-foreground mr-1 icon-[lucide--search] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ref, computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -227,9 +193,10 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton } = defineProps<{
const { group, showInfoButton, showNodeIdBadge } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -238,10 +205,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
@@ -256,73 +219,17 @@ const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
const isUnknownPack = computed(
() => group.packId === null && !group.isResolving
)
const packDisplayName = computed(() => {
if (group.packId === null) {
return t('rightSidePanel.missingNodePacks.unknownPack')
}
return nodePack.value?.name ?? group.packId
})
const isPackInstalled = computed(
() => group.packId !== null && comfyManagerStore.isPackInstalled(group.packId)
)
const showInstallAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
(nodePack.value !== null || isPackInstalled.value)
)
const showLoadingAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
isLoading.value
)
const showSearchAction = computed(
() =>
shouldShowManagerButtons.value &&
group.packId !== null &&
!showInstallAction.value &&
!showLoadingAction.value
)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const showNodeCount = computed(() => group.nodeTypes.length !== 1)
const expanded = computed(
() =>
expandedOverride.value ??
(isUnknownPack.value && hasMultipleNodeTypes.value)
)
const showNodeTypeList = computed(
() =>
(isUnknownPack.value && group.nodeTypes.length === 1) ||
(hasMultipleNodeTypes.value && expanded.value)
)
const primaryLocatableNodeType = computed(() => {
if (group.isResolving) return null
if (isUnknownPack.value) return null
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function handlePackInstallClick() {
if (!group.packId) return
if (!isPackInstalled.value) {
if (!comfyManagerStore.isPackInstalled(group.packId)) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expandedOverride.value = !expanded.value
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
@@ -334,14 +241,10 @@ function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (!isLocatableNodeType(nodeType)) return
emit('locateNode', String(nodeType.nodeId))
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
}
</script>

View File

@@ -148,6 +148,7 @@
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"

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
}
}
}

View File

@@ -0,0 +1,44 @@
import { computed } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { MODELS_TAG } from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import { useLocalModelLibrarySource } from './useLocalModelLibrarySource'
// Unified Model Library data source. The cloud distribution reads from the
// assets API via useAssetsStore; desktop and localhost distributions enumerate
// the on-disk models folder. Consumers see the same AssetItem[] shape either
// way so the sidebar component renders without branching on mode.
export interface ModelLibrarySource {
assets: ComputedRef<AssetItem[]>
isLoading: ComputedRef<boolean> | Ref<boolean>
refresh: () => Promise<void>
}
const CLOUD_CACHE_KEY = `tag:${MODELS_TAG}`
export function useModelLibrarySource(): ModelLibrarySource {
if (!isCloud) {
return useLocalModelLibrarySource()
}
const assetsStore = useAssetsStore()
async function refresh(): Promise<void> {
await assetsStore.updateModelsForTag(MODELS_TAG)
}
const assets = computed<AssetItem[]>(() =>
assetsStore.getAssets(CLOUD_CACHE_KEY)
)
const isLoading = computed(
() =>
assetsStore.isModelLoading(CLOUD_CACHE_KEY) && assets.value.length === 0
)
return { assets, isLoading, refresh }
}

View File

@@ -0,0 +1,31 @@
import { useStorage } from '@vueuse/core'
import type { Ref } from 'vue'
// localStorage-backed MRU list of model identifiers (asset filenames) the user
// has picked from a node's model widget. Surfaced as a "Recently used" section
// at the top of the model dropdown so users can jump back to recent picks.
//
// Stored as a flat array; most recently used first. Capped to keep storage
// bounded and the popover scannable.
const STORAGE_KEY = 'Comfy.NodeModelWidget.RecentlyUsed.v1'
const MAX_ENTRIES = 16
const TOP_DISPLAY = 3
const recentNames: Ref<string[]> = useStorage<string[]>(STORAGE_KEY, [])
export function useRecentlyUsedModels() {
function markUsed(name: string): void {
const trimmed = name?.trim()
if (!trimmed) return
const next = [trimmed, ...recentNames.value.filter((n) => n !== trimmed)]
recentNames.value = next.slice(0, MAX_ENTRIES)
}
return {
recentNames,
/** Names to render in the "Recently used" section, most recent first. */
topNames: () => recentNames.value.slice(0, TOP_DISPLAY),
markUsed
}
}

View File

@@ -4,6 +4,8 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -15,6 +17,11 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const isCopyDropSource = (type: unknown) =>
type === 'tree-explorer-node' ||
type === 'cloud-model-asset' ||
type === 'partner-node'
export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
const modelToNodeStore = useModelToNodeStore()
const litegraphService = useLitegraphService()
@@ -22,11 +29,29 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
usePragmaticDroppable(() => canvasRef.value, {
getDropEffect: (args): Exclude<DataTransfer['dropEffect'], 'none'> =>
args.source.data.type === 'tree-explorer-node' ? 'copy' : 'move',
isCopyDropSource(args.source.data.type) ? 'copy' : 'move',
onDrop: async (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'cloud-model-asset') {
const asset = dndData.asset as AssetItem
const conv = useSharedCanvasPositionConversion()
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
createModelNodeFromAsset(asset, { position: basePos })
return
}
if (dndData.type === 'partner-node') {
const nodeDef = dndData.nodeDef as ComfyNodeDefImpl
const conv = useSharedCanvasPositionConversion()
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
const pos: Point = [...basePos]
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
return
}
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
const conv = useSharedCanvasPositionConversion()

View File

@@ -21,6 +21,7 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -53,6 +54,7 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import {
@@ -1300,6 +1302,11 @@ export function useCoreCommands(): ComfyCommand[] {
await settingStore.set('Comfy.Assets.UseAssetAPI', true)
await workflowService.reloadCurrentWorkflow()
}
if (isCloud) {
const sidebarTabStore = useSidebarTabStore()
sidebarTabStore.toggleSidebarTab('model-library')
return
}
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models',

View File

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

View File

@@ -2765,14 +2765,15 @@
"selectModel": "Select model",
"uploadSelect": {
"placeholder": "Select...",
"placeholderImage": "Select image...",
"placeholderImage": "Search media assets",
"placeholderAudio": "Select audio...",
"placeholderVideo": "Select video...",
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached",
"topResult": "Top result: {result}"
"topResult": "Top result: {result}",
"importMedia": "Import media"
},
"valueControl": {
"header": {
@@ -3014,6 +3015,34 @@
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
"assets": {
"sort": {
"tooltip": "Sort",
"recent": "Most recent first",
"oldest": "Oldest first",
"nameAsc": "Name AZ",
"nameDesc": "Name ZA",
"baseModelAsc": "Base model AZ",
"baseModelDesc": "Base model ZA"
},
"searchResults": "Search results"
},
"cloudModelLibrary": {
"preview": {
"createsNode": "Creates node",
"triggerWords": "Trigger words",
"description": "Description",
"nodePreview": "Node preview",
"url": "URL",
"openUrl": "Open URL"
},
"contextMenu": {
"addToGraph": "Add to graph",
"copyFilename": "Copy filename",
"copyNodeName": "Copy node name",
"openOnHuggingFace": "Open on Hugging Face"
}
},
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",
@@ -3107,6 +3136,7 @@
"selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type",
"selectProjects": "Select Projects",
"recentlyUsed": "Recently used",
"sortAZ": "A-Z",
"sortDefault": "Default",
"sortBy": "Sort by",
@@ -3628,10 +3658,12 @@
"unsupportedTitle": "Unsupported Node Packs",
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
"installAll": "Install All",
"installNodePack": "Install node pack",
"unknownPack": "Unknown pack",
"installing": "Installing...",
"installed": "Installed",
"applyChanges": "Apply Changes",
"searchInManager": "Search in Node Manager",
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"

View File

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

View File

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

View File

@@ -150,6 +150,7 @@ const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
)
vi.mock('../services/assetService', () => ({
MODELS_TAG: 'models',
assetService: {
deleteAsset: mockDeleteAsset,
createAssetExport: mockCreateAssetExport

View File

@@ -8,6 +8,7 @@ import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
MODELS_TAG: 'models',
assetService: {
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()

View File

@@ -89,12 +89,20 @@ describe(assetService.shouldUseAssetBrowser, () => {
mockSettingStoreGet.mockReturnValue(false)
})
it('returns false when not on cloud', () => {
it('returns true on local for an eligible model widget regardless of the asset API setting', () => {
mockDistributionState.isCloud = false
mockSettingStoreGet.mockReturnValue(true)
mockSettingStoreGet.mockReturnValue(false)
expect(
assetService.shouldUseAssetBrowser('CheckpointLoaderSimple', 'ckpt_name')
).toBe(true)
})
it('returns false on local for an ineligible widget', () => {
mockDistributionState.isCloud = false
expect(
assetService.shouldUseAssetBrowser('UnknownNode', 'some_input')
).toBe(false)
})

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