mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-10 08:18:38 +00:00
Compare commits
14 Commits
jaeone/fe-
...
synap5e/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf481f2c71 | ||
|
|
45fef1d89c | ||
|
|
cf996d8e39 | ||
|
|
ebf70db149 | ||
|
|
ed4f7db7f4 | ||
|
|
39157f2375 | ||
|
|
47118ef64f | ||
|
|
f110af79f7 | ||
|
|
8972d27689 | ||
|
|
72d1261983 | ||
|
|
6c0c603576 | ||
|
|
a13d6cf99e | ||
|
|
c7873ac7ed | ||
|
|
87625d852b |
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
BIN
apps/website/public/affiliates/brand/comfy-amplified-logo.png
Normal file
BIN
apps/website/public/affiliates/brand/comfy-amplified-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -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 |
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
60
apps/website/src/components/blocks/BenefitsGrid01.vue
Normal file
60
apps/website/src/components/blocks/BenefitsGrid01.vue
Normal 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>
|
||||
65
apps/website/src/components/blocks/BrandAssetsGrid01.vue
Normal file
65
apps/website/src/components/blocks/BrandAssetsGrid01.vue
Normal 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>
|
||||
56
apps/website/src/components/blocks/ChecklistSplit01.vue
Normal file
56
apps/website/src/components/blocks/ChecklistSplit01.vue
Normal 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>
|
||||
50
apps/website/src/components/blocks/CtaCenter01.vue
Normal file
50
apps/website/src/components/blocks/CtaCenter01.vue
Normal 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>
|
||||
94
apps/website/src/components/blocks/FAQSplit01.vue
Normal file
94
apps/website/src/components/blocks/FAQSplit01.vue
Normal 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>
|
||||
153
apps/website/src/components/blocks/HeroSplit01.vue
Normal file
153
apps/website/src/components/blocks/HeroSplit01.vue
Normal 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>
|
||||
91
apps/website/src/components/blocks/StepsGrid01.vue
Normal file
91
apps/website/src/components/blocks/StepsGrid01.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
23
apps/website/src/components/icons/CheckIcon.vue
Normal file
23
apps/website/src/components/icons/CheckIcon.vue
Normal 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>
|
||||
32
apps/website/src/components/icons/NodeUnionIcon.vue
Normal file
32
apps/website/src/components/icons/NodeUnionIcon.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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] }}
|
||||
|
||||
@@ -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
|
||||
|
||||
152
apps/website/src/components/models/ModelCreationsSection.vue
Normal file
152
apps/website/src/components/models/ModelCreationsSection.vue
Normal 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>
|
||||
62
apps/website/src/components/models/ModelsHeroSection.vue
Normal file
62
apps/website/src/components/models/ModelsHeroSection.vue
Normal 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>
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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
@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
|
||||
hubSlug: 'seedance',
|
||||
featured: true
|
||||
},
|
||||
'grok-image': {
|
||||
'grok-imagine': {
|
||||
hubSlug: 'grok',
|
||||
featured: false
|
||||
},
|
||||
|
||||
44
apps/website/src/data/affiliateAudience.ts
Normal file
44
apps/website/src/data/affiliateAudience.ts
Normal 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
|
||||
39
apps/website/src/data/affiliateBenefits.ts
Normal file
39
apps/website/src/data/affiliateBenefits.ts
Normal 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
|
||||
38
apps/website/src/data/affiliateBrandAssets.ts
Normal file
38
apps/website/src/data/affiliateBrandAssets.ts
Normal 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
|
||||
103
apps/website/src/data/affiliateFaq.ts
Normal file
103
apps/website/src/data/affiliateFaq.ts
Normal 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
|
||||
45
apps/website/src/data/affiliateHowItWorks.ts
Normal file
45
apps/website/src/data/affiliateHowItWorks.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 world’s\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>>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
22
apps/website/src/pages/models.astro
Normal file
22
apps/website/src/pages/models.astro
Normal 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>
|
||||
23
apps/website/src/pages/zh-CN/models.astro
Normal file
23
apps/website/src/pages/zh-CN/models.astro
Normal 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>
|
||||
22
apps/website/src/templates/affiliate/AudienceSection.vue
Normal file
22
apps/website/src/templates/affiliate/AudienceSection.vue
Normal 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>
|
||||
27
apps/website/src/templates/affiliate/BenefitsSection.vue
Normal file
27
apps/website/src/templates/affiliate/BenefitsSection.vue
Normal 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>
|
||||
25
apps/website/src/templates/affiliate/BrandAssetsSection.vue
Normal file
25
apps/website/src/templates/affiliate/BrandAssetsSection.vue
Normal 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>
|
||||
26
apps/website/src/templates/affiliate/CtaSection.vue
Normal file
26
apps/website/src/templates/affiliate/CtaSection.vue
Normal 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>
|
||||
19
apps/website/src/templates/affiliate/FAQSection.vue
Normal file
19
apps/website/src/templates/affiliate/FAQSection.vue
Normal 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>
|
||||
32
apps/website/src/templates/affiliate/HeroSection.vue
Normal file
32
apps/website/src/templates/affiliate/HeroSection.vue
Normal 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>
|
||||
22
apps/website/src/templates/affiliate/HowItWorksSection.vue
Normal file
22
apps/website/src/templates/affiliate/HowItWorksSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
125
src/components/sidebar/tabs/cloudModelLibrary/CloudModelLeaf.vue
Normal file
125
src/components/sidebar/tabs/cloudModelLibrary/CloudModelLeaf.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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] ?? []
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
215
src/components/sidebar/tabs/cloudModelLibrary/modelGroups.ts
Normal file
215
src/components/sidebar/tabs/cloudModelLibrary/modelGroups.ts
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 A–Z 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', '—'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
57
src/composables/sidebarTabs/useCategoryPlaceholder.ts
Normal file
57
src/composables/sidebarTabs/useCategoryPlaceholder.ts
Normal 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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
117
src/composables/sidebarTabs/useLocalModelLibrarySource.ts
Normal file
117
src/composables/sidebarTabs/useLocalModelLibrarySource.ts
Normal 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
|
||||
}
|
||||
123
src/composables/sidebarTabs/useModelLibraryHoverPopover.ts
Normal file
123
src/composables/sidebarTabs/useModelLibraryHoverPopover.ts
Normal 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
|
||||
}
|
||||
}
|
||||
42
src/composables/sidebarTabs/useModelLibraryLeaf.ts
Normal file
42
src/composables/sidebarTabs/useModelLibraryLeaf.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/composables/sidebarTabs/useModelLibrarySource.ts
Normal file
44
src/composables/sidebarTabs/useModelLibrarySource.ts
Normal 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 }
|
||||
}
|
||||
31
src/composables/sidebarTabs/useRecentlyUsedModels.ts
Normal file
31
src/composables/sidebarTabs/useRecentlyUsedModels.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
27
src/composables/useImageQuiet.ts
Normal file
27
src/composables/useImageQuiet.ts
Normal 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.
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 A–Z",
|
||||
"nameDesc": "Name Z–A",
|
||||
"baseModelAsc": "Base model A–Z",
|
||||
"baseModelDesc": "Base model Z–A"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -390,17 +390,20 @@ function createAssetService() {
|
||||
|
||||
/**
|
||||
* Checks if the asset browser should be used for a given node input.
|
||||
* Combines the cloud environment check, user setting, and eligibility check.
|
||||
*
|
||||
* @param nodeType - The ComfyUI node comfyClass
|
||||
* @param widgetName - The name of the widget to check
|
||||
* @returns true if this input should use the asset browser
|
||||
* Activates in two cases:
|
||||
* - cloud: when the user has opted into the Assets API and the input is
|
||||
* a recognised model widget on a registered loader node.
|
||||
* - desktop / localhost: any registered model loader widget, since the
|
||||
* local Model Library source already enumerates /models/<folder>.
|
||||
*/
|
||||
function shouldUseAssetBrowser(
|
||||
nodeType: string | undefined,
|
||||
widgetName: string
|
||||
): boolean {
|
||||
return isAssetAPIEnabled() && isAssetBrowserEligible(nodeType, widgetName)
|
||||
if (!isAssetBrowserEligible(nodeType, widgetName)) return false
|
||||
if (isCloud) return isAssetAPIEnabled()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,14 @@ export interface OwnershipFilterOption {
|
||||
* - 'name-asc': Sort by display name A-Z
|
||||
* - 'name-desc': Sort by display name Z-A
|
||||
*/
|
||||
export type AssetSortOption = 'default' | 'recent' | 'name-asc' | 'name-desc'
|
||||
export type AssetSortOption =
|
||||
| 'default'
|
||||
| 'recent'
|
||||
| 'oldest'
|
||||
| 'name-asc'
|
||||
| 'name-desc'
|
||||
| 'author-asc'
|
||||
| 'author-desc'
|
||||
|
||||
/**
|
||||
* Filter state for asset browser and filter bar
|
||||
|
||||
@@ -145,12 +145,30 @@ describe('assetMetadataUtils', () => {
|
||||
name: 'filters non-string values from array',
|
||||
trained_words: ['valid', 123, 'also valid', null],
|
||||
expected: ['valid', 'also valid']
|
||||
},
|
||||
{
|
||||
name: 'strips trailing-comma artifacts from each phrase',
|
||||
trained_words: ['freckles,', 'detailed eyes,', 'perfect skin texture'],
|
||||
expected: ['freckles', 'detailed eyes', 'perfect skin texture']
|
||||
},
|
||||
{
|
||||
name: 'splits a comma-joined string into separate phrases',
|
||||
trained_words: 'detailed eyes, perfect eyes, freckles',
|
||||
expected: ['detailed eyes', 'perfect eyes', 'freckles']
|
||||
}
|
||||
])('$name', ({ trained_words, expected }) => {
|
||||
const asset = { ...mockAsset, user_metadata: { trained_words } }
|
||||
expect(getAssetTriggerPhrases(asset)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('falls back to the local trigger_phrase when no trained_words', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
metadata: { trigger_phrase: 'magic word' }
|
||||
}
|
||||
expect(getAssetTriggerPhrases(asset)).toEqual(['magic word'])
|
||||
})
|
||||
|
||||
it('should return empty array when no metadata', () => {
|
||||
expect(getAssetTriggerPhrases(mockAsset)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
inferBaseModelFromText,
|
||||
refineBaseModelLabels
|
||||
} from '@/components/sidebar/tabs/cloudModelLibrary/baseModelInference'
|
||||
import { getBaseModelOverrides } from '@/components/sidebar/tabs/cloudModelLibrary/baseModelOverrides'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCivitaiUrl } from '@/utils/formatUtil'
|
||||
|
||||
@@ -46,13 +51,38 @@ export function getAssetBaseModel(asset: AssetItem): string | null {
|
||||
* @returns Array of base model strings
|
||||
*/
|
||||
export function getAssetBaseModels(asset: AssetItem): string[] {
|
||||
const filenameSources = [
|
||||
asset.name,
|
||||
typeof asset.metadata?.filename === 'string'
|
||||
? asset.metadata.filename
|
||||
: undefined,
|
||||
typeof asset.metadata?.filepath === 'string'
|
||||
? asset.metadata.filepath
|
||||
: undefined
|
||||
].filter((s): s is string => Boolean(s))
|
||||
|
||||
const baseModel =
|
||||
asset.user_metadata?.base_model ?? asset.metadata?.base_model
|
||||
let labels: string[] = []
|
||||
if (Array.isArray(baseModel)) {
|
||||
return baseModel.filter((m): m is string => typeof m === 'string')
|
||||
labels = baseModel.filter((m): m is string => typeof m === 'string')
|
||||
} else if (typeof baseModel === 'string' && baseModel) {
|
||||
labels = [baseModel]
|
||||
} else {
|
||||
const repoId = asset.metadata?.repo_id
|
||||
if (typeof repoId === 'string' && repoId) {
|
||||
labels = [...getBaseModelOverrides(repoId)]
|
||||
}
|
||||
}
|
||||
if (typeof baseModel === 'string' && baseModel) {
|
||||
return [baseModel]
|
||||
|
||||
// base_model can name the family root (e.g. `Lightricks/LTX-Video`) while the
|
||||
// filename names a specific variant (`LTX_2.3_…`); let inference refine it.
|
||||
if (labels.length > 0) return refineBaseModelLabels(labels, filenameSources)
|
||||
|
||||
// Civitai LoRAs etc. carry no repo_id or base_model — infer from filename.
|
||||
for (const source of filenameSources) {
|
||||
const inferred = inferBaseModelFromText(source)
|
||||
if (inferred) return [inferred]
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -93,19 +123,38 @@ export function getAssetSourceUrl(asset: AssetItem): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts trigger phrases from asset metadata
|
||||
* Checks user_metadata first, then metadata, then returns empty array
|
||||
* Extracts trigger phrases from asset metadata.
|
||||
*
|
||||
* Cloud assets expose Civitai-style `trained_words` (an array). Local assets
|
||||
* read from safetensors expose a single `trigger_phrase` string (from the
|
||||
* `modelspec.trigger_phrase` header), so fall back to that when no
|
||||
* `trained_words` are present.
|
||||
*
|
||||
* Values are comma-delimited in the source data, often with trailing-comma
|
||||
* artifacts (e.g. `"freckles,"`). Splitting on commas and trimming yields
|
||||
* clean phrases for both display and copy-to-clipboard.
|
||||
*
|
||||
* Checks user_metadata first, then metadata.
|
||||
* @param asset - The asset to extract trigger phrases from
|
||||
* @returns Array of trigger phrases
|
||||
*/
|
||||
export function getAssetTriggerPhrases(asset: AssetItem): string[] {
|
||||
const phrases =
|
||||
asset.user_metadata?.trained_words ?? asset.metadata?.trained_words
|
||||
if (Array.isArray(phrases)) {
|
||||
return phrases.filter((p): p is string => typeof p === 'string')
|
||||
const raw = Array.isArray(phrases)
|
||||
? phrases.filter((p): p is string => typeof p === 'string')
|
||||
: typeof phrases === 'string' && phrases
|
||||
? [phrases]
|
||||
: []
|
||||
if (raw.length === 0) {
|
||||
const single =
|
||||
asset.user_metadata?.trigger_phrase ?? asset.metadata?.trigger_phrase
|
||||
if (typeof single === 'string') raw.push(single)
|
||||
}
|
||||
if (typeof phrases === 'string') return [phrases]
|
||||
return []
|
||||
return raw
|
||||
.flatMap((entry) => entry.split(','))
|
||||
.map((phrase) => phrase.trim())
|
||||
.filter((phrase) => phrase.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { AssetSortOption } from '../types/filterTypes'
|
||||
export interface SortableItem {
|
||||
name: string
|
||||
label?: string
|
||||
author?: string
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
@@ -19,6 +20,10 @@ function getDisplayName(item: SortableItem): string {
|
||||
return item.label ?? item.name
|
||||
}
|
||||
|
||||
function getAuthorKey(item: SortableItem): string {
|
||||
return item.author?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort items by the specified sort option
|
||||
* @param items - Array of sortable items
|
||||
@@ -49,6 +54,34 @@ export function sortAssets<T extends SortableItem>(
|
||||
new Date(b.created_at ?? 0).getTime() -
|
||||
new Date(a.created_at ?? 0).getTime()
|
||||
)
|
||||
case 'oldest':
|
||||
return sorted.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at ?? 0).getTime() -
|
||||
new Date(b.created_at ?? 0).getTime()
|
||||
)
|
||||
case 'author-asc':
|
||||
case 'author-desc': {
|
||||
const direction = sortBy === 'author-desc' ? -1 : 1
|
||||
const hasAuthor = (i: SortableItem) => !!i.author?.trim()
|
||||
return sorted.sort((a, b) => {
|
||||
const ah = hasAuthor(a)
|
||||
const bh = hasAuthor(b)
|
||||
// Always sink unknown-author rows to the bottom, irrespective of
|
||||
// direction — keeps the "Other" bucket visually anchored at the end.
|
||||
if (ah !== bh) return ah ? -1 : 1
|
||||
const authorCmp =
|
||||
direction *
|
||||
getAuthorKey(a).localeCompare(getAuthorKey(b), undefined, {
|
||||
sensitivity: 'base'
|
||||
})
|
||||
if (authorCmp !== 0) return authorCmp
|
||||
return getDisplayName(a).localeCompare(getDisplayName(b), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
})
|
||||
}
|
||||
case 'name-asc':
|
||||
default:
|
||||
return sorted.sort((a, b) =>
|
||||
|
||||
@@ -35,12 +35,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
|
||||
let testId = 0
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
delete window.__comfyDesktop2
|
||||
delete window.__comfyDesktop2Remote
|
||||
})
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
testId++
|
||||
})
|
||||
|
||||
@@ -242,7 +247,126 @@ describe('downloadModel', () => {
|
||||
beforeEach(() => {
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
})
|
||||
|
||||
it('uses the Desktop2 bridge directly instead of the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).toHaveBeenCalledWith(
|
||||
'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
'model.safetensors',
|
||||
'checkpoints'
|
||||
)
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs Desktop2 bridge failures without falling back to browser download', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockRejectedValue(bridgeError)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs synchronous Desktop2 bridge failures without crashing', async () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const bridgeError = new Error('bridge failed before returning a promise')
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockImplementation(() => {
|
||||
throw bridgeError
|
||||
})
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to start Desktop2 model download:',
|
||||
bridgeError
|
||||
)
|
||||
})
|
||||
expect(anchorClick).not.toHaveBeenCalled()
|
||||
expect(mockStartDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps remote Desktop2 sessions on the browser fallback', () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {})
|
||||
const desktopDownloadModel = vi
|
||||
.fn<
|
||||
(url: string, filename: string, directory: string) => Promise<boolean>
|
||||
>()
|
||||
.mockResolvedValue(true)
|
||||
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
|
||||
window.__comfyDesktop2Remote = true
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(desktopDownloadModel).not.toHaveBeenCalled()
|
||||
expect(anchorClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the model library sidebar before starting a desktop download', () => {
|
||||
|
||||
@@ -3,6 +3,21 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
interface ComfyDesktop2Bridge {
|
||||
downloadModel: (
|
||||
url: string,
|
||||
filename: string,
|
||||
directory: string
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__comfyDesktop2?: ComfyDesktop2Bridge
|
||||
__comfyDesktop2Remote?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
'https://civitai.red/',
|
||||
@@ -35,6 +50,17 @@ export interface ModelWithUrl {
|
||||
directory: string
|
||||
}
|
||||
|
||||
async function startDesktop2ModelDownload(
|
||||
bridge: ComfyDesktop2Bridge,
|
||||
model: ModelWithUrl
|
||||
): Promise<void> {
|
||||
try {
|
||||
await bridge.downloadModel(model.url, model.name, model.directory)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to start Desktop2 model download:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a model download URL to a browsable page URL.
|
||||
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
|
||||
@@ -63,6 +89,12 @@ export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
const desktop2Bridge = window.__comfyDesktop2
|
||||
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
|
||||
void startDesktop2ModelDownload(desktop2Bridge, model)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
|
||||
@@ -208,6 +208,23 @@ describe('GtmTelemetryProvider', () => {
|
||||
expect(entry!.error as string).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('pushes execution_start', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_start'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes execution_success with job_id', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackExecutionSuccess({ jobId: 'job-1' })
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'execution_success',
|
||||
job_id: 'job-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes select_content for template events', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackTemplate({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user