mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-10 16:29:26 +00:00
Compare commits
23 Commits
uy/node-se
...
synap5e/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9649228bd | ||
|
|
bf481f2c71 | ||
|
|
45fef1d89c | ||
|
|
cf996d8e39 | ||
|
|
ebf70db149 | ||
|
|
ed4f7db7f4 | ||
|
|
39157f2375 | ||
|
|
47118ef64f | ||
|
|
f110af79f7 | ||
|
|
8972d27689 | ||
|
|
72d1261983 | ||
|
|
1b90696459 | ||
|
|
e340b5b127 | ||
|
|
68238a5742 | ||
|
|
183a75a218 | ||
|
|
507e667795 | ||
|
|
d617ab1c95 | ||
|
|
b93338cac7 | ||
|
|
fa2d187c83 | ||
|
|
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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const LINUX_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
const IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
|
||||
test.describe('Download page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -52,6 +56,61 @@ test.describe('Download page @smoke', () => {
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection falls back to both Windows + Mac when UA is unrecognized', async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({ userAgent: LINUX_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
const macBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/mac/dmg/arm64"]'
|
||||
)
|
||||
await expect(macBtn).toBeVisible()
|
||||
await expect(macBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toHaveCount(2)
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('HeroSection hides every desktop CTA on mobile', async ({ browser }) => {
|
||||
const context = await browser.newContext({ userAgent: IPHONE_UA })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/download')
|
||||
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
).toBeVisible()
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
|
||||
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) }}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
|
||||
import {
|
||||
downloadUrls,
|
||||
useDownloadUrl
|
||||
} from '../../../composables/useDownloadUrl'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
|
||||
@@ -12,32 +15,64 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { downloadUrl, platform } = useDownloadUrl()
|
||||
const { downloadUrl, platform, showFallback } = useDownloadUrl()
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'mac':
|
||||
return '/icons/os/apple.svg'
|
||||
case 'windows':
|
||||
return '/icons/os/windows.svg'
|
||||
default:
|
||||
return undefined
|
||||
const ICONS = {
|
||||
windows: '/icons/os/windows.svg',
|
||||
mac: '/icons/os/apple.svg'
|
||||
} as const
|
||||
|
||||
interface ButtonSpec {
|
||||
key: string
|
||||
href: string
|
||||
icon: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
const buttons = computed<ButtonSpec[]>(() => {
|
||||
if (platform.value) {
|
||||
return [
|
||||
{
|
||||
key: platform.value,
|
||||
href: downloadUrl.value,
|
||||
icon: ICONS[platform.value]
|
||||
}
|
||||
]
|
||||
}
|
||||
if (showFallback.value) {
|
||||
const label = t('download.hero.downloadLocal', locale)
|
||||
return [
|
||||
{
|
||||
key: 'windows',
|
||||
href: downloadUrls.windows,
|
||||
icon: ICONS.windows,
|
||||
ariaLabel: `${label} — Windows`
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
href: downloadUrls.macArm,
|
||||
icon: ICONS.mac,
|
||||
ariaLabel: `${label} — macOS`
|
||||
}
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BrandButton
|
||||
v-show="platform"
|
||||
:href="downloadUrl"
|
||||
v-for="btn in buttons"
|
||||
:key="btn.key"
|
||||
:href="btn.href"
|
||||
target="_blank"
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<img
|
||||
v-if="iconSrc"
|
||||
:src="iconSrc"
|
||||
:src="btn.icon"
|
||||
alt=""
|
||||
class="ppformula-text-center size-5 -translate-y-0.75"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
const downloadUrls = {
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
@@ -24,6 +24,8 @@ function detectPlatform(ua: string): DetectedPlatform {
|
||||
// When Linux and/or macIntel builds are added, extend detection and URLs here.
|
||||
export function useDownloadUrl() {
|
||||
const platform = ref<DetectedPlatform>(null)
|
||||
const detected = ref(false)
|
||||
const isMobileUa = ref(false)
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
if (platform.value === 'windows') return downloadUrls.windows
|
||||
@@ -31,9 +33,16 @@ export function useDownloadUrl() {
|
||||
return externalLinks.github
|
||||
})
|
||||
|
||||
const showFallback = computed(
|
||||
() => detected.value && !platform.value && !isMobileUa.value
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
platform.value = detectPlatform(navigator.userAgent.toLowerCase())
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
isMobileUa.value = isMobile(ua)
|
||||
platform.value = detectPlatform(ua)
|
||||
detected.value = true
|
||||
})
|
||||
|
||||
return { downloadUrl, platform }
|
||||
return { downloadUrl, platform, showFallback }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -1879,8 +1879,9 @@ const translations = {
|
||||
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
|
||||
},
|
||||
'privacy.intro.block.3': {
|
||||
en: 'This policy is effective as of April 18, 2025.',
|
||||
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
|
||||
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
|
||||
'zh-CN':
|
||||
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop(本地安装应用程序)的具体信息,包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
|
||||
},
|
||||
'privacy.information-we-collect.label': {
|
||||
en: 'INFORMATION',
|
||||
@@ -2130,6 +2131,181 @@ const translations = {
|
||||
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
|
||||
},
|
||||
|
||||
// ── Desktop Privacy Policy ────────────────────────────────────────
|
||||
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
|
||||
'desktop_privacy.intro.block.0': {
|
||||
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
|
||||
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
|
||||
},
|
||||
'desktop_privacy.intro.block.1': {
|
||||
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
|
||||
'zh-CN':
|
||||
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
|
||||
},
|
||||
|
||||
'desktop_privacy.controller.label': {
|
||||
en: 'CONTROLLER',
|
||||
'zh-CN': 'CONTROLLER'
|
||||
},
|
||||
'desktop_privacy.controller.title': {
|
||||
en: 'Controller',
|
||||
'zh-CN': 'Controller'
|
||||
},
|
||||
'desktop_privacy.controller.block.0': {
|
||||
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
'desktop_privacy.data.label': {
|
||||
en: 'DATA WE PROCESS',
|
||||
'zh-CN': 'DATA WE PROCESS'
|
||||
},
|
||||
'desktop_privacy.data.title': {
|
||||
en: 'Personal data we process',
|
||||
'zh-CN': 'Personal data we process'
|
||||
},
|
||||
'desktop_privacy.data.block.0': {
|
||||
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
|
||||
'zh-CN':
|
||||
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
|
||||
},
|
||||
'desktop_privacy.data.block.1': {
|
||||
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
|
||||
'zh-CN':
|
||||
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
|
||||
},
|
||||
'desktop_privacy.data.block.2': {
|
||||
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
|
||||
'zh-CN':
|
||||
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
|
||||
},
|
||||
'desktop_privacy.data.block.3': {
|
||||
en: 'We do not process:',
|
||||
'zh-CN': 'We do not process:'
|
||||
},
|
||||
'desktop_privacy.data.block.4': {
|
||||
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
|
||||
'zh-CN':
|
||||
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
|
||||
},
|
||||
'desktop_privacy.data.block.5': {
|
||||
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
|
||||
'zh-CN':
|
||||
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
|
||||
},
|
||||
|
||||
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
|
||||
'desktop_privacy.purposes.title': {
|
||||
en: 'Purposes and lawful bases',
|
||||
'zh-CN': 'Purposes and lawful bases'
|
||||
},
|
||||
'desktop_privacy.purposes.block.0': {
|
||||
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
|
||||
'zh-CN':
|
||||
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
|
||||
},
|
||||
'desktop_privacy.purposes.block.1': {
|
||||
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
|
||||
'zh-CN':
|
||||
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
|
||||
},
|
||||
'desktop_privacy.purposes.block.2': {
|
||||
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
'desktop_privacy.purposes.block.3': {
|
||||
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
|
||||
},
|
||||
|
||||
'desktop_privacy.processors.label': {
|
||||
en: 'RECIPIENTS',
|
||||
'zh-CN': 'RECIPIENTS'
|
||||
},
|
||||
'desktop_privacy.processors.title': {
|
||||
en: 'Recipients',
|
||||
'zh-CN': 'Recipients'
|
||||
},
|
||||
'desktop_privacy.processors.block.0': {
|
||||
en: 'We engage the following processors under Data Processing Agreements:',
|
||||
'zh-CN':
|
||||
'We engage the following processors under Data Processing Agreements:'
|
||||
},
|
||||
'desktop_privacy.processors.block.1': {
|
||||
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
|
||||
'zh-CN':
|
||||
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
|
||||
},
|
||||
|
||||
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
|
||||
'desktop_privacy.transfers.title': {
|
||||
en: 'International transfers',
|
||||
'zh-CN': 'International transfers'
|
||||
},
|
||||
'desktop_privacy.transfers.block.0': {
|
||||
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
|
||||
},
|
||||
|
||||
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
|
||||
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
|
||||
'desktop_privacy.retention.block.0': {
|
||||
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
|
||||
'zh-CN':
|
||||
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
|
||||
},
|
||||
|
||||
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
|
||||
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
|
||||
'desktop_privacy.rights.block.0': {
|
||||
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
|
||||
'zh-CN':
|
||||
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
|
||||
},
|
||||
'desktop_privacy.rights.block.1': {
|
||||
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
|
||||
},
|
||||
'desktop_privacy.rights.block.2': {
|
||||
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
|
||||
'zh-CN':
|
||||
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
|
||||
},
|
||||
'desktop_privacy.rights.block.3': {
|
||||
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
|
||||
'zh-CN':
|
||||
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
|
||||
},
|
||||
|
||||
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
|
||||
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
|
||||
'desktop_privacy.children.block.0': {
|
||||
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
|
||||
'zh-CN':
|
||||
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
|
||||
},
|
||||
|
||||
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
|
||||
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
|
||||
'desktop_privacy.changes.block.0': {
|
||||
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
|
||||
'zh-CN':
|
||||
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
|
||||
},
|
||||
|
||||
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
|
||||
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
|
||||
'desktop_privacy.contact.block.0': {
|
||||
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
// ── Terms of Service ──────────────────────────────────────────────
|
||||
'tos.effectiveDateLabel': {
|
||||
en: 'Effective Date',
|
||||
@@ -4458,6 +4634,80 @@ const translations = {
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
|
||||
// Models list page (/models)
|
||||
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
|
||||
'models.list.heroCta': {
|
||||
en: 'Try {name} Now',
|
||||
'zh-CN': '立即试用 {name}'
|
||||
},
|
||||
'models.list.creations.title': {
|
||||
en: '{name} Image and Video Creations',
|
||||
'zh-CN': '{name} 图像与视频创作'
|
||||
},
|
||||
'models.list.creations.cta': {
|
||||
en: 'Explore Workflows',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.list.heroTitle.before': {
|
||||
en: '{name} in',
|
||||
'zh-CN': ''
|
||||
},
|
||||
'models.list.heroTitle.after': {
|
||||
en: '',
|
||||
'zh-CN': ' 中的 {name}'
|
||||
},
|
||||
'models.list.heroSubtitle': {
|
||||
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
|
||||
'zh-CN':
|
||||
'从开源扩散模型到合作伙伴 API,涵盖每一个主流模型,并附带可直接运行的社区工作流模板。'
|
||||
},
|
||||
'models.list.card.workflows': {
|
||||
en: '{count} workflows',
|
||||
'zh-CN': '{count} 个工作流'
|
||||
},
|
||||
'models.list.contact.label': {
|
||||
en: 'COMFY HUB',
|
||||
'zh-CN': 'COMFY HUB'
|
||||
},
|
||||
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
|
||||
'models.showcase.heading': {
|
||||
en: 'Run the 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',
|
||||
@@ -4501,313 +4751,103 @@ const translations = {
|
||||
'zh-CN': '查看订阅文档'
|
||||
},
|
||||
|
||||
// Affiliate landing page (/affiliates)
|
||||
// English-only copy; zh-CN values mirror en until a localized version lands.
|
||||
'affiliate-landing.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org Affiliate Program — Become a Partner'
|
||||
// AffiliateHeroSection
|
||||
'affiliate.hero.label': { en: 'AFFILIATE', 'zh-CN': '联盟' },
|
||||
'affiliate.hero.headingHighlight': {
|
||||
en: 'Earn 30%',
|
||||
'zh-CN': '赚取 30%'
|
||||
},
|
||||
'affiliate-landing.page.description': {
|
||||
'affiliate.hero.headingMuted': {
|
||||
en: 'recurring commission for 3 months.',
|
||||
'zh-CN': '持续返佣 3 个月。'
|
||||
},
|
||||
'affiliate.hero.feature1': {
|
||||
en: '30% recurring commission for 3 months',
|
||||
'zh-CN': '30% 持续佣金,连续 3 个月'
|
||||
},
|
||||
'affiliate.hero.feature2': {
|
||||
en: '60-day cookie window',
|
||||
'zh-CN': '60 天 Cookie 窗口'
|
||||
},
|
||||
'affiliate.hero.feature3': {
|
||||
en: '$100 minimum payout',
|
||||
'zh-CN': '$100 起付'
|
||||
},
|
||||
'affiliate.hero.feature4': {
|
||||
en: 'Monthly payouts',
|
||||
'zh-CN': '每月结算'
|
||||
},
|
||||
'affiliate.hero.apply': { en: 'APPLY NOW', 'zh-CN': '立即申请' },
|
||||
'affiliate.hero.imageAlt': {
|
||||
en: 'Comfy affiliate program',
|
||||
'zh-CN': 'Comfy 联盟计划'
|
||||
},
|
||||
|
||||
// AffiliateAudienceSection
|
||||
'affiliate.audience.heading': {
|
||||
en: "Who we're looking for",
|
||||
'zh-CN': '我们在寻找谁'
|
||||
},
|
||||
'affiliate.audience.subheading': {
|
||||
en: 'If you are...',
|
||||
'zh-CN': '如果您是……'
|
||||
},
|
||||
|
||||
// AffiliateHowItWorksSection
|
||||
'affiliate.howItWorks.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': '运作方式'
|
||||
},
|
||||
|
||||
// AffiliateBenefitsSection
|
||||
'affiliate.benefits.heading': {
|
||||
en: 'Why ComfyUI for affiliate creators',
|
||||
'zh-CN': '为什么联盟创作者选择 ComfyUI'
|
||||
},
|
||||
|
||||
// AffiliateBrandAssetsSection
|
||||
'affiliate.assets.heading': {
|
||||
en: 'Brand logos for your content',
|
||||
'zh-CN': '可用于您内容的品牌 Logo'
|
||||
},
|
||||
'affiliate.assets.subheading': {
|
||||
en: 'Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
|
||||
'zh-CN': '横幅图、截图和宣传文案将在获批后于联盟仪表盘中提供。'
|
||||
},
|
||||
'affiliate.assets.downloadLabel': {
|
||||
en: 'Download zip',
|
||||
'zh-CN': '下载压缩包'
|
||||
},
|
||||
|
||||
// AffiliateFAQSection
|
||||
'affiliate.faq.heading': {
|
||||
en: 'Frequently asked questions',
|
||||
'zh-CN': '常见问题'
|
||||
},
|
||||
|
||||
// Affiliate page (/affiliates) — head metadata
|
||||
'affiliate.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org 联盟计划 — 成为合作伙伴'
|
||||
},
|
||||
'affiliate.page.description': {
|
||||
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
|
||||
'zh-CN':
|
||||
'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.'
|
||||
},
|
||||
'affiliate-landing.cta.apply': {
|
||||
en: 'Apply Now',
|
||||
'zh-CN': 'Apply Now'
|
||||
},
|
||||
'affiliate-landing.cta.applyAriaLabel': {
|
||||
en: 'Apply Now (opens in new tab)',
|
||||
'zh-CN': 'Apply Now (opens in new tab)'
|
||||
'为您推荐的每个 Comfy Cloud 订阅赚取 30% 持续佣金,连续 3 个月。立即申请成为 Comfy 合作伙伴。'
|
||||
},
|
||||
|
||||
// Hero
|
||||
'affiliate-landing.hero.heading': {
|
||||
en: 'Become a Comfy Partner',
|
||||
'zh-CN': 'Become a Comfy Partner'
|
||||
},
|
||||
'affiliate-landing.hero.subheading': {
|
||||
en: 'Earn 30% Commission for 3 Months.',
|
||||
'zh-CN': 'Earn 30% Commission for 3 Months.'
|
||||
},
|
||||
'affiliate-landing.hero.body': {
|
||||
en: 'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:',
|
||||
'zh-CN':
|
||||
'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.0': {
|
||||
en: '30% recurring commission for 3 months',
|
||||
'zh-CN': '30% recurring commission for 3 months'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.1': {
|
||||
en: '60-day cookie window',
|
||||
'zh-CN': '60-day cookie window'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.2': {
|
||||
en: '$100 minimum payout',
|
||||
'zh-CN': '$100 minimum payout'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.3': {
|
||||
en: 'Monthly payouts',
|
||||
'zh-CN': 'Monthly payouts'
|
||||
},
|
||||
|
||||
// Trust band
|
||||
'affiliate-landing.trust.label': {
|
||||
en: 'Trusted by 2M+ creators worldwide',
|
||||
'zh-CN': 'Trusted by 2M+ creators worldwide'
|
||||
},
|
||||
|
||||
// How it works
|
||||
'affiliate-landing.how.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': 'How it works'
|
||||
},
|
||||
'affiliate-landing.how.step.0.title': {
|
||||
en: 'Apply.',
|
||||
'zh-CN': 'Apply.'
|
||||
},
|
||||
'affiliate-landing.how.step.0.body': {
|
||||
en: 'Submit a quick form. Most applicants approved same day.',
|
||||
'zh-CN': 'Submit a quick form. Most applicants approved same day.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.title': {
|
||||
en: 'Share.',
|
||||
'zh-CN': 'Share.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.body': {
|
||||
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
|
||||
'zh-CN':
|
||||
'Get your unique tracking link. Share via content, social, email, however you reach your audience.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.title': {
|
||||
en: 'Earn.',
|
||||
'zh-CN': 'Earn.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.body': {
|
||||
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
|
||||
'zh-CN':
|
||||
'30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.'
|
||||
},
|
||||
|
||||
// Who we're looking for
|
||||
'affiliate-landing.audience.heading': {
|
||||
en: "Who we're looking for",
|
||||
'zh-CN': "Who we're looking for"
|
||||
},
|
||||
'affiliate-landing.audience.item.0': {
|
||||
en: 'ComfyUI tutorial creators and workflow builders',
|
||||
'zh-CN': 'ComfyUI tutorial creators and workflow builders'
|
||||
},
|
||||
'affiliate-landing.audience.item.1': {
|
||||
en: 'AI tool reviewers on YouTube, TikTok, blogs',
|
||||
'zh-CN': 'AI tool reviewers on YouTube, TikTok, blogs'
|
||||
},
|
||||
'affiliate-landing.audience.item.2': {
|
||||
en: 'Tech bloggers covering AI creative tools',
|
||||
'zh-CN': 'Tech bloggers covering AI creative tools'
|
||||
},
|
||||
'affiliate-landing.audience.item.3': {
|
||||
en: 'Newsletter operators in the AI/creative space',
|
||||
'zh-CN': 'Newsletter operators in the AI/creative space'
|
||||
},
|
||||
'affiliate-landing.audience.item.4': {
|
||||
en: 'Anyone with an audience interested in AI image, video, or 3D generation',
|
||||
'zh-CN':
|
||||
'Anyone with an audience interested in AI image, video, or 3D generation'
|
||||
},
|
||||
|
||||
// Program details
|
||||
'affiliate-landing.details.heading': {
|
||||
en: 'Program details',
|
||||
'zh-CN': 'Program details'
|
||||
},
|
||||
'affiliate-landing.details.headerLabel': {
|
||||
en: 'Detail',
|
||||
'zh-CN': 'Detail'
|
||||
},
|
||||
'affiliate-landing.details.headerValue': {
|
||||
en: 'Value',
|
||||
'zh-CN': 'Value'
|
||||
},
|
||||
'affiliate-landing.details.row.0.label': {
|
||||
en: 'Commission rate',
|
||||
'zh-CN': 'Commission rate'
|
||||
},
|
||||
'affiliate-landing.details.row.0.value': {
|
||||
en: '30% recurring',
|
||||
'zh-CN': '30% recurring'
|
||||
},
|
||||
'affiliate-landing.details.row.1.label': {
|
||||
en: 'Commission duration',
|
||||
'zh-CN': 'Commission duration'
|
||||
},
|
||||
'affiliate-landing.details.row.1.value': {
|
||||
en: '3 months',
|
||||
'zh-CN': '3 months'
|
||||
},
|
||||
'affiliate-landing.details.row.2.label': {
|
||||
en: 'Cookie window',
|
||||
'zh-CN': 'Cookie window'
|
||||
},
|
||||
'affiliate-landing.details.row.2.value': {
|
||||
en: '60 days',
|
||||
'zh-CN': '60 days'
|
||||
},
|
||||
'affiliate-landing.details.row.3.label': {
|
||||
en: 'Eligible products',
|
||||
'zh-CN': 'Eligible products'
|
||||
},
|
||||
'affiliate-landing.details.row.3.value': {
|
||||
en: 'Comfy Cloud paid subscription plans',
|
||||
'zh-CN': 'Comfy Cloud paid subscription plans'
|
||||
},
|
||||
'affiliate-landing.details.row.4.label': {
|
||||
en: 'Payouts',
|
||||
'zh-CN': 'Payouts'
|
||||
},
|
||||
'affiliate-landing.details.row.4.value': {
|
||||
en: 'Monthly, within first 10 business days',
|
||||
'zh-CN': 'Monthly, within first 10 business days'
|
||||
},
|
||||
'affiliate-landing.details.row.5.label': {
|
||||
en: 'Minimum payout',
|
||||
'zh-CN': 'Minimum payout'
|
||||
},
|
||||
'affiliate-landing.details.row.5.value': {
|
||||
en: '$100',
|
||||
'zh-CN': '$100'
|
||||
},
|
||||
|
||||
// Brand assets
|
||||
'affiliate-landing.assets.heading': {
|
||||
en: 'Brand assets',
|
||||
'zh-CN': 'Brand assets'
|
||||
},
|
||||
'affiliate-landing.assets.subheading': {
|
||||
en: 'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.'
|
||||
},
|
||||
'affiliate-landing.assets.downloadLabel': {
|
||||
en: 'Download',
|
||||
'zh-CN': 'Download'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logo-horizontal.title': {
|
||||
en: 'Comfy logo (horizontal)',
|
||||
'zh-CN': 'Comfy logo (horizontal)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logomark.title': {
|
||||
en: 'Comfy logomark',
|
||||
'zh-CN': 'Comfy logomark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-yellow.title': {
|
||||
en: 'Comfy full logo (yellow)',
|
||||
'zh-CN': 'Comfy full logo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-ink.title': {
|
||||
en: 'Comfy full logo (ink)',
|
||||
'zh-CN': 'Comfy full logo (ink)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.amplified-logo-mark.title': {
|
||||
en: 'Amplified logo mark',
|
||||
'zh-CN': 'Amplified logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.dimensional-logo-mark.title': {
|
||||
en: 'Dimensional logo mark',
|
||||
'zh-CN': 'Dimensional logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-yellow.title': {
|
||||
en: 'Color combo (yellow)',
|
||||
'zh-CN': 'Color combo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-ink.title': {
|
||||
en: 'Color combo (ink)',
|
||||
'zh-CN': 'Color combo (ink)'
|
||||
},
|
||||
|
||||
// FAQ — keys follow the FAQSection contract: <prefix>.<n>.q / <prefix>.<n>.a, 1-indexed
|
||||
'affiliate-landing.faq.heading': {
|
||||
en: 'Questions',
|
||||
'zh-CN': 'Questions'
|
||||
},
|
||||
'affiliate-landing.faq.1.q': {
|
||||
en: 'How do I track my referrals?',
|
||||
'zh-CN': 'How do I track my referrals?'
|
||||
},
|
||||
'affiliate-landing.faq.1.a': {
|
||||
en: 'Real-time dashboard via our partner portal.',
|
||||
'zh-CN': 'Real-time dashboard via our partner portal.'
|
||||
},
|
||||
'affiliate-landing.faq.2.q': {
|
||||
en: 'What plans qualify?',
|
||||
'zh-CN': 'What plans qualify?'
|
||||
},
|
||||
'affiliate-landing.faq.2.a': {
|
||||
en: 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).',
|
||||
'zh-CN': 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).'
|
||||
},
|
||||
'affiliate-landing.faq.3.q': {
|
||||
en: 'How long does approval take?',
|
||||
'zh-CN': 'How long does approval take?'
|
||||
},
|
||||
'affiliate-landing.faq.3.a': {
|
||||
en: 'Most applications approved within 24 hours.',
|
||||
'zh-CN': 'Most applications approved within 24 hours.'
|
||||
},
|
||||
'affiliate-landing.faq.4.q': {
|
||||
en: 'When do I get paid?',
|
||||
'zh-CN': 'When do I get paid?'
|
||||
},
|
||||
'affiliate-landing.faq.4.a': {
|
||||
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
|
||||
'zh-CN':
|
||||
'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.'
|
||||
},
|
||||
'affiliate-landing.faq.5.q': {
|
||||
en: 'What happens if my referral upgrades or downgrades?',
|
||||
'zh-CN': 'What happens if my referral upgrades or downgrades?'
|
||||
},
|
||||
'affiliate-landing.faq.5.a': {
|
||||
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
|
||||
'zh-CN':
|
||||
'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.'
|
||||
},
|
||||
'affiliate-landing.faq.6.q': {
|
||||
en: 'Can I use coupon codes?',
|
||||
'zh-CN': 'Can I use coupon codes?'
|
||||
},
|
||||
'affiliate-landing.faq.6.a': {
|
||||
en: 'Yes. We support both tracking links and unique coupon codes.',
|
||||
'zh-CN': 'Yes. We support both tracking links and unique coupon codes.'
|
||||
},
|
||||
'affiliate-landing.faq.7.q': {
|
||||
en: 'What if my referral uses an ad blocker?',
|
||||
'zh-CN': 'What if my referral uses an ad blocker?'
|
||||
},
|
||||
'affiliate-landing.faq.7.a': {
|
||||
en: 'We use server-side tracking, so conversions are tracked regardless.',
|
||||
'zh-CN':
|
||||
'We use server-side tracking, so conversions are tracked regardless.'
|
||||
},
|
||||
'affiliate-landing.faq.8.q': {
|
||||
en: 'What assets do you provide?',
|
||||
'zh-CN': 'What assets do you provide?'
|
||||
},
|
||||
'affiliate-landing.faq.8.a': {
|
||||
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.'
|
||||
},
|
||||
|
||||
// Footer CTA
|
||||
'affiliate-landing.footerCta.heading': {
|
||||
// AffiliateCtaSection
|
||||
'affiliate.cta.heading': {
|
||||
en: 'Ready to start earning?',
|
||||
'zh-CN': 'Ready to start earning?'
|
||||
'zh-CN': '准备好开始赚取佣金了吗?'
|
||||
},
|
||||
'affiliate-landing.footerCta.termsLink': {
|
||||
'affiliate.cta.apply': {
|
||||
en: 'APPLY NOW',
|
||||
'zh-CN': '立即申请'
|
||||
},
|
||||
'affiliate.cta.termsLabel': {
|
||||
en: 'Read the affiliate program terms',
|
||||
'zh-CN': 'Read the affiliate program terms'
|
||||
'zh-CN': '阅读联盟计划条款'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -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>
|
||||
13
apps/website/src/pages/privacy/desktop.astro
Normal file
13
apps/website/src/pages/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop Privacy Policy — Comfy"
|
||||
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
|
||||
>
|
||||
<HeroSection title="Desktop Privacy Policy" />
|
||||
<ContentSection prefix="desktop_privacy" client:load />
|
||||
</BaseLayout>
|
||||
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>
|
||||
13
apps/website/src/pages/zh-CN/privacy/desktop.astro
Normal file
13
apps/website/src/pages/zh-CN/privacy/desktop.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop 隐私政策 — Comfy"
|
||||
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
|
||||
>
|
||||
<HeroSection title="Desktop 隐私政策" />
|
||||
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
|
||||
</BaseLayout>
|
||||
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>
|
||||
@@ -69,11 +69,6 @@ export const TestIds = {
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
|
||||
@@ -545,4 +545,54 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Responsive Layout', () => {
|
||||
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 800 })
|
||||
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Add new keybinding/i })
|
||||
).toBeInViewport()
|
||||
|
||||
const hasHorizontalScroll = await page
|
||||
.locator('.keybinding-panel .p-datatable-table-container')
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
|
||||
expect(hasHorizontalScroll).toBe(false)
|
||||
})
|
||||
|
||||
test('Keybinding column compresses with width while actions stay reachable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
const keybindingList = row.getByTestId('keybinding-list')
|
||||
await expect(keybindingList).toBeVisible()
|
||||
|
||||
const listWidthAt = async (viewportWidth: number) => {
|
||||
await page.setViewportSize({ width: viewportWidth, height: 800 })
|
||||
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
const wideWidth = await listWidthAt(1280)
|
||||
const narrowWidth = await listWidthAt(560)
|
||||
|
||||
expect(narrowWidth).toBeLessThan(wideWidth)
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaUploadDropzone
|
||||
)
|
||||
const [fileChooser] = await Promise.all([
|
||||
comfyPage.page.waitForEvent('filechooser'),
|
||||
dropzone.click()
|
||||
])
|
||||
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
|
||||
}
|
||||
|
||||
async function confirmPendingSelection(comfyPage: ComfyPage) {
|
||||
const confirmButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaConfirmButton
|
||||
)
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
await confirmButton.click()
|
||||
}
|
||||
|
||||
function getMediaRow(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
|
||||
}
|
||||
|
||||
function getStatusCard(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
|
||||
}
|
||||
|
||||
function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
test('Shows missing item label and locate action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Upload flow', () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Library select flow', () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
test.describe('List behavior', () => {
|
||||
test('Clicking the missing item label navigates canvas to the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
)
|
||||
await librarySelect.getByRole('combobox').click()
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
|
||||
const optionCount = await comfyPage.page.getByRole('option').count()
|
||||
if (optionCount === 0) {
|
||||
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
await comfyPage.page.getByRole('option').first().click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
|
||||
await confirmPendingSelection(comfyPage)
|
||||
await expect(getMediaRow(comfyPage)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cancel selection', () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
await expect(getDropzone(comfyPage)).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
|
||||
await getMediaRow(comfyPage)
|
||||
.getByRole('button', { name: 'Load Image - image', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeHidden()
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('All resolved', () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
return canvas?.ds?.offset
|
||||
? [canvas.ds.offset[0], canvas.ds.offset[1]]
|
||||
: null
|
||||
})
|
||||
})
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.10",
|
||||
"version": "1.46.11",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -28,13 +28,15 @@ export type {
|
||||
BillingPlansResponse,
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
BulkRevokeApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysData,
|
||||
BulkRevokeWorkspaceMemberApiKeysError,
|
||||
BulkRevokeWorkspaceMemberApiKeysErrors,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponses,
|
||||
CancelAssetSeedData,
|
||||
CancelAssetSeedResponse,
|
||||
CancelAssetSeedResponses,
|
||||
CancelJobData,
|
||||
CancelJobError,
|
||||
CancelJobErrors,
|
||||
@@ -57,11 +59,14 @@ export type {
|
||||
CheckHubUsernameResponse,
|
||||
CheckHubUsernameResponses,
|
||||
ClientOptions,
|
||||
CreateAssetData,
|
||||
CreateAssetDownloadData,
|
||||
CreateAssetDownloadError,
|
||||
CreateAssetDownloadErrors,
|
||||
CreateAssetDownloadResponse,
|
||||
CreateAssetDownloadResponses,
|
||||
CreateAssetError,
|
||||
CreateAssetErrors,
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportError,
|
||||
CreateAssetExportErrors,
|
||||
@@ -72,6 +77,8 @@ export type {
|
||||
CreateAssetFromHashErrors,
|
||||
CreateAssetFromHashResponse,
|
||||
CreateAssetFromHashResponses,
|
||||
CreateAssetResponse,
|
||||
CreateAssetResponses,
|
||||
CreateDeletionRequestData,
|
||||
CreateDeletionRequestError,
|
||||
CreateDeletionRequestErrors,
|
||||
@@ -208,6 +215,8 @@ export type {
|
||||
ForkWorkflowRequest,
|
||||
ForkWorkflowResponse,
|
||||
ForkWorkflowResponses,
|
||||
FreeMemoryData,
|
||||
FreeMemoryResponses,
|
||||
GetAllSettingsData,
|
||||
GetAllSettingsError,
|
||||
GetAllSettingsErrors,
|
||||
@@ -221,6 +230,9 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
GetAssetTagHistogramData,
|
||||
GetAssetTagHistogramError,
|
||||
GetAssetTagHistogramErrors,
|
||||
@@ -259,6 +271,9 @@ export type {
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetEmbeddingsData,
|
||||
GetEmbeddingsResponse,
|
||||
GetEmbeddingsResponses,
|
||||
GetExtensionsData,
|
||||
GetExtensionsResponse,
|
||||
GetExtensionsResponses,
|
||||
@@ -305,6 +320,18 @@ export type {
|
||||
GetHubWorkflowErrors,
|
||||
GetHubWorkflowResponse,
|
||||
GetHubWorkflowResponses,
|
||||
GetI18nData,
|
||||
GetI18nResponse,
|
||||
GetI18nResponses,
|
||||
GetInternalFolderPathsData,
|
||||
GetInternalFolderPathsResponse,
|
||||
GetInternalFolderPathsResponses,
|
||||
GetInternalLogsData,
|
||||
GetInternalLogsRawData,
|
||||
GetInternalLogsRawResponse,
|
||||
GetInternalLogsRawResponses,
|
||||
GetInternalLogsResponse,
|
||||
GetInternalLogsResponses,
|
||||
GetJobDetailData,
|
||||
GetJobDetailError,
|
||||
GetJobDetailErrors,
|
||||
@@ -356,10 +383,7 @@ export type {
|
||||
GetModelFoldersResponse,
|
||||
GetModelFoldersResponses,
|
||||
GetModelPreviewData,
|
||||
GetModelPreviewError,
|
||||
GetModelPreviewErrors,
|
||||
GetModelPreviewResponse,
|
||||
GetModelPreviewResponses,
|
||||
GetModelsInFolderData,
|
||||
GetModelsInFolderError,
|
||||
GetModelsInFolderErrors,
|
||||
@@ -389,8 +413,26 @@ export type {
|
||||
GetNodeReplacementsErrors,
|
||||
GetNodeReplacementsResponse,
|
||||
GetNodeReplacementsResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetOAuthAuthorizationServerData,
|
||||
GetOAuthAuthorizationServerError,
|
||||
GetOAuthAuthorizationServerErrors,
|
||||
GetOAuthAuthorizationServerResponse,
|
||||
GetOAuthAuthorizationServerResponses,
|
||||
GetOAuthAuthorizeData,
|
||||
GetOAuthAuthorizeError,
|
||||
GetOAuthAuthorizeErrors,
|
||||
GetOAuthAuthorizeResponse,
|
||||
GetOAuthAuthorizeResponses,
|
||||
GetOAuthProtectedResourceByPathData,
|
||||
GetOAuthProtectedResourceByPathError,
|
||||
GetOAuthProtectedResourceByPathErrors,
|
||||
GetOAuthProtectedResourceByPathResponse,
|
||||
GetOAuthProtectedResourceByPathResponses,
|
||||
GetOAuthProtectedResourceData,
|
||||
GetOAuthProtectedResourceError,
|
||||
GetOAuthProtectedResourceErrors,
|
||||
GetOAuthProtectedResourceResponse,
|
||||
GetOAuthProtectedResourceResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
@@ -427,11 +469,11 @@ export type {
|
||||
GetSecretErrors,
|
||||
GetSecretResponse,
|
||||
GetSecretResponses,
|
||||
GetSettingByKeyData,
|
||||
GetSettingByKeyError,
|
||||
GetSettingByKeyErrors,
|
||||
GetSettingByKeyResponse,
|
||||
GetSettingByKeyResponses,
|
||||
GetSettingByIdData,
|
||||
GetSettingByIdError,
|
||||
GetSettingByIdErrors,
|
||||
GetSettingByIdResponse,
|
||||
GetSettingByIdResponses,
|
||||
GetStaticExtensionsData,
|
||||
GetStaticExtensionsErrors,
|
||||
GetStaticExtensionsResponses,
|
||||
@@ -447,6 +489,7 @@ export type {
|
||||
GetTaskResponses,
|
||||
GetTemplateProxyData,
|
||||
GetTemplateProxyErrors,
|
||||
GetTemplateProxyResponses,
|
||||
GetUserData,
|
||||
GetUserdataData,
|
||||
GetUserdataError,
|
||||
@@ -534,6 +577,11 @@ export type {
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InsertDynamicConfigData,
|
||||
InsertDynamicConfigError,
|
||||
InsertDynamicConfigErrors,
|
||||
InsertDynamicConfigResponse,
|
||||
InsertDynamicConfigResponses,
|
||||
InterruptJobData,
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
@@ -642,6 +690,17 @@ export type {
|
||||
MoveUserdataFileResponse,
|
||||
MoveUserdataFileResponses,
|
||||
NodeInfo,
|
||||
OAuthAuthorizationServerMetadata,
|
||||
OAuthAuthorizeRedirectResponse,
|
||||
OAuthConsentChallenge,
|
||||
OAuthConsentChallengeWorkspace,
|
||||
OAuthProtectedResourceMetadata,
|
||||
OAuthRegisterBadRequestResponse,
|
||||
OAuthRegisterError,
|
||||
OAuthRegisterRequest,
|
||||
OAuthRegisterResponse,
|
||||
OAuthTokenError,
|
||||
OAuthTokenResponse,
|
||||
PaginationInfo,
|
||||
PartnerUsageRequest,
|
||||
PartnerUsageResponse,
|
||||
@@ -663,6 +722,21 @@ export type {
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
PostOAuthAuthorizeData,
|
||||
PostOAuthAuthorizeError,
|
||||
PostOAuthAuthorizeErrors,
|
||||
PostOAuthAuthorizeResponse,
|
||||
PostOAuthAuthorizeResponses,
|
||||
PostOAuthRegisterData,
|
||||
PostOAuthRegisterError,
|
||||
PostOAuthRegisterErrors,
|
||||
PostOAuthRegisterResponse,
|
||||
PostOAuthRegisterResponses,
|
||||
PostOAuthTokenData,
|
||||
PostOAuthTokenError,
|
||||
PostOAuthTokenErrors,
|
||||
PostOAuthTokenResponse,
|
||||
PostOAuthTokenResponses,
|
||||
PostPprofSymbolData,
|
||||
PostPprofSymbolResponses,
|
||||
PostUserdataFileData,
|
||||
@@ -687,6 +761,9 @@ export type {
|
||||
PromptInfo,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
PruneAssetsData,
|
||||
PruneAssetsResponse,
|
||||
PruneAssetsResponses,
|
||||
PublishedWorkflowDetail,
|
||||
PublishHubWorkflowData,
|
||||
PublishHubWorkflowError,
|
||||
@@ -732,6 +809,9 @@ export type {
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
SeedAssetsResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
@@ -751,6 +831,8 @@ export type {
|
||||
SubscribeResponse,
|
||||
SubscribeResponse2,
|
||||
SubscribeResponses,
|
||||
SubscribeToLogsData,
|
||||
SubscribeToLogsResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SyncApiKeyData,
|
||||
@@ -771,11 +853,6 @@ export type {
|
||||
UpdateAssetErrors,
|
||||
UpdateAssetResponse,
|
||||
UpdateAssetResponses,
|
||||
UpdateAssetTagsData,
|
||||
UpdateAssetTagsError,
|
||||
UpdateAssetTagsErrors,
|
||||
UpdateAssetTagsResponse,
|
||||
UpdateAssetTagsResponses,
|
||||
UpdateHubProfileData,
|
||||
UpdateHubProfileError,
|
||||
UpdateHubProfileErrors,
|
||||
@@ -799,11 +876,11 @@ export type {
|
||||
UpdateSecretRequest,
|
||||
UpdateSecretResponse,
|
||||
UpdateSecretResponses,
|
||||
UpdateSettingByKeyData,
|
||||
UpdateSettingByKeyError,
|
||||
UpdateSettingByKeyErrors,
|
||||
UpdateSettingByKeyResponse,
|
||||
UpdateSettingByKeyResponses,
|
||||
UpdateSettingByIdData,
|
||||
UpdateSettingByIdError,
|
||||
UpdateSettingByIdErrors,
|
||||
UpdateSettingByIdResponse,
|
||||
UpdateSettingByIdResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
@@ -821,11 +898,6 @@ export type {
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
UploadAssetData,
|
||||
UploadAssetError,
|
||||
UploadAssetErrors,
|
||||
UploadAssetResponse,
|
||||
UploadAssetResponses,
|
||||
UploadImageData,
|
||||
UploadImageError,
|
||||
UploadImageErrors,
|
||||
|
||||
1263
packages/ingest-types/src/types.gen.ts
generated
1263
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
602
packages/ingest-types/src/zod.gen.ts
generated
602
packages/ingest-types/src/zod.gen.ts
generated
@@ -399,13 +399,18 @@ export const zCreateWorkflowVersionRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Offset/limit-based pagination metadata included in list responses.
|
||||
* Pagination metadata included in list responses. Supports both legacy
|
||||
* offset/limit pagination and cursor-based pagination. When cursor-based
|
||||
* pagination is used, `next_cursor` is the primary pagination token and
|
||||
* `offset`/`total` may be zero.
|
||||
*
|
||||
*/
|
||||
export const zPaginationInfo = z.object({
|
||||
offset: z.number().int().gte(0),
|
||||
limit: z.number().int().gte(1),
|
||||
total: z.number().int().gte(0),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -879,6 +884,155 @@ export const zJwkKey = z.object({
|
||||
y: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.2 error response.
|
||||
*/
|
||||
export const zOAuthTokenError = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.1 successful token response.
|
||||
*/
|
||||
export const zOAuthTokenResponse = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.enum(['Bearer']),
|
||||
expires_in: z.number().int(),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallengeWorkspace = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['personal', 'team']),
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
|
||||
*/
|
||||
export const zOAuthAuthorizeRedirectResponse = z.object({
|
||||
redirect_url: z.string().url()
|
||||
})
|
||||
|
||||
/**
|
||||
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallenge = z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 protected-resource metadata (RFC 9728).
|
||||
*/
|
||||
export const zOAuthProtectedResourceMetadata = z.object({
|
||||
resource: z.string().url(),
|
||||
authorization_servers: z.array(z.string().url()),
|
||||
scopes_supported: z.array(z.string()),
|
||||
bearer_methods_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.2 error response.
|
||||
*/
|
||||
export const zOAuthRegisterError = z.object({
|
||||
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
|
||||
error_description: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
export const zErrorResponse = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `ErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs, normalized to the standard {code, message} shape by the custom Echo HTTPErrorHandler (BE-1178).
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterBadRequestResponse = z.union([
|
||||
zOAuthRegisterError,
|
||||
zErrorResponse
|
||||
])
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.1 successful registration response.
|
||||
*/
|
||||
export const zOAuthRegisterResponse = z.object({
|
||||
client_id: z.string(),
|
||||
client_id_issued_at: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
client_name: z.string().optional(),
|
||||
redirect_uris: z.array(z.string()),
|
||||
grant_types: z.array(z.string()),
|
||||
response_types: z.array(z.string()),
|
||||
token_endpoint_auth_method: z.enum(['none']),
|
||||
application_type: z.enum(['native', 'web'])
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterRequest = z.object({
|
||||
redirect_uris: z.array(z.string()).min(1).max(5),
|
||||
client_name: z.string().max(100).optional(),
|
||||
application_type: z.enum(['native', 'web']).optional(),
|
||||
token_endpoint_auth_method: z.enum(['none']).optional(),
|
||||
grant_types: z
|
||||
.array(z.enum(['authorization_code', 'refresh_token']))
|
||||
.optional(),
|
||||
response_types: z.array(z.enum(['code'])).optional(),
|
||||
scope: z.string().nullish(),
|
||||
resource_grants: z.record(z.array(z.string())).nullish(),
|
||||
client_uri: z.string().nullish(),
|
||||
logo_uri: z.string().nullish(),
|
||||
tos_uri: z.string().nullish(),
|
||||
policy_uri: z.string().nullish(),
|
||||
software_id: z.string().nullish(),
|
||||
software_version: z.string().nullish(),
|
||||
contacts: z.array(z.string()).nullish(),
|
||||
jwks: z.record(z.unknown()).nullish(),
|
||||
jwks_uri: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 authorization-server metadata (RFC 8414).
|
||||
*/
|
||||
export const zOAuthAuthorizationServerMetadata = z.object({
|
||||
issuer: z.string().url(),
|
||||
authorization_endpoint: z.string().url(),
|
||||
token_endpoint: z.string().url(),
|
||||
jwks_uri: z.string().url(),
|
||||
registration_endpoint: z.string().url().optional(),
|
||||
response_types_supported: z.array(z.string()),
|
||||
grant_types_supported: z.array(z.string()),
|
||||
code_challenge_methods_supported: z.array(z.string()),
|
||||
token_endpoint_auth_methods_supported: z.array(z.string()),
|
||||
scopes_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
|
||||
*/
|
||||
@@ -940,6 +1094,7 @@ export const zWorkspaceApiKeyInfo = z.object({
|
||||
workspace_id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
last_used_at: z.string().datetime().optional(),
|
||||
@@ -960,6 +1115,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
|
||||
export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
@@ -971,6 +1127,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
*/
|
||||
export const zCreateWorkspaceApiKeyRequest = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().max(5000).optional(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
@@ -1353,7 +1510,8 @@ export const zListTagsResponse = z.object({
|
||||
export const zAsset = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
@@ -1364,19 +1522,20 @@ export const zAsset = z.object({
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
mime_type: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).readonly().optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
is_immutable: z.boolean().optional()
|
||||
is_immutable: z.boolean().optional(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1385,7 +1544,8 @@ export const zAsset = z.object({
|
||||
export const zListAssetsResponse = z.object({
|
||||
assets: z.array(zAsset),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1394,14 +1554,17 @@ export const zListAssetsResponse = z.object({
|
||||
export const zAssetUpdated = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().optional(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
mime_type: z.string().optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
updated_at: z.string().datetime()
|
||||
job_id: z.string().uuid().nullish(),
|
||||
updated_at: z.string().datetime(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1753,21 +1916,6 @@ export const zExportDownloadUrlResponse = z.object({
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Error shape returned when request binding or validation fails before the handler runs.
|
||||
*/
|
||||
export const zBindingErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
export const zErrorResponse = z.object({
|
||||
code: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response returned after successfully queuing a workflow prompt.
|
||||
*/
|
||||
@@ -1796,7 +1944,8 @@ export const zPromptRequest = z.object({
|
||||
export const zAssetWritable = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
asset_hash: z
|
||||
display_name: z.string().nullish(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
@@ -1807,18 +1956,19 @@ export const zAssetWritable = z.object({
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
mime_type: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
prompt_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
last_access_time: z.string().datetime().optional(),
|
||||
is_immutable: z.boolean().optional()
|
||||
is_immutable: z.boolean().optional(),
|
||||
file_path: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1827,7 +1977,8 @@ export const zAssetWritable = z.object({
|
||||
export const zListAssetsResponseWritable = z.object({
|
||||
assets: z.array(zAssetWritable),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1961,21 +2112,6 @@ export const zGetModelsInFolderData = z.object({
|
||||
*/
|
||||
export const zGetModelsInFolderResponse = z.array(zModelFile)
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Model preview image
|
||||
*/
|
||||
export const zGetModelPreviewResponse = z.string()
|
||||
|
||||
export const zGetLegacyHistoryData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2027,6 +2163,7 @@ export const zListJobsData = z.object({
|
||||
output_type: z.enum(['image', 'video', 'audio', '3d']).optional(),
|
||||
sort_by: z.enum(['create_time', 'execution_time']).optional(),
|
||||
sort_order: z.enum(['asc', 'desc']).optional(),
|
||||
after: z.string().optional(),
|
||||
offset: z.number().int().gte(0).optional().default(0),
|
||||
limit: z.number().int().gte(1).lte(1000).optional().default(100)
|
||||
})
|
||||
@@ -2132,9 +2269,9 @@ export const zListAssetsData = z.object({
|
||||
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
|
||||
.optional(),
|
||||
order: z.enum(['asc', 'desc']).optional(),
|
||||
job_ids: z.array(z.string().uuid()).optional(),
|
||||
include_public: z.boolean().optional().default(true),
|
||||
asset_hash: z.string().optional()
|
||||
hash: z.string().optional(),
|
||||
after: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -2144,26 +2281,34 @@ export const zListAssetsData = z.object({
|
||||
*/
|
||||
export const zListAssetsResponse2 = zListAssetsResponse
|
||||
|
||||
export const zUploadAssetData = z.object({
|
||||
export const zCreateAssetData = z.object({
|
||||
body: z.object({
|
||||
url: z.string().url(),
|
||||
name: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_id: z.string().uuid().optional()
|
||||
file: z.string(),
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^(blake3|sha256):[a-f0-9]{64}$/)
|
||||
.optional(),
|
||||
tags: z.string().optional(),
|
||||
id: z.string().uuid().optional(),
|
||||
preview_id: z.string().uuid().optional(),
|
||||
name: z.string().optional(),
|
||||
mime_type: z.string().optional(),
|
||||
user_metadata: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset already exists (returned existing asset)
|
||||
* Asset already existed for this user (deduplicated by content hash); the
|
||||
* existing asset is returned with created_new=false.
|
||||
*
|
||||
*/
|
||||
export const zUploadAssetResponse = zAssetCreated
|
||||
export const zCreateAssetResponse = zAssetCreated
|
||||
|
||||
export const zCreateAssetFromHashData = z.object({
|
||||
body: z.object({
|
||||
hash: z.string().regex(/^(blake3|sha256):[a-f0-9]{64}$/),
|
||||
hash: z.string().regex(/^blake3:[a-f0-9]{64}$/),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).min(1),
|
||||
mime_type: z.string().optional(),
|
||||
@@ -2174,7 +2319,9 @@ export const zCreateAssetFromHashData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset reference already exists (returned existing)
|
||||
* Asset reference already existed for this user (deduplicated by content
|
||||
* hash); the existing asset is returned with created_new=false.
|
||||
*
|
||||
*/
|
||||
export const zCreateAssetFromHashResponse = zAssetCreated
|
||||
|
||||
@@ -2214,7 +2361,8 @@ export const zCreateAssetExportData = z.object({
|
||||
naming_strategy: z
|
||||
.enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
|
||||
.optional(),
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
|
||||
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional(),
|
||||
include_previews: z.boolean().optional().default(false)
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
@@ -2247,7 +2395,7 @@ export const zDeleteAssetData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset deleted successfully
|
||||
* Asset record deleted successfully
|
||||
*/
|
||||
export const zDeleteAssetResponse = z.void()
|
||||
|
||||
@@ -2312,22 +2460,6 @@ export const zAddAssetTagsData = z.object({
|
||||
*/
|
||||
export const zAddAssetTagsResponse = zTagsModificationResponse
|
||||
|
||||
export const zUpdateAssetTagsData = z.object({
|
||||
body: z.object({
|
||||
add: z.array(z.string()).optional(),
|
||||
remove: z.array(z.string()).optional()
|
||||
}),
|
||||
path: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Tags updated successfully
|
||||
*/
|
||||
export const zUpdateAssetTagsResponse = zTagsModificationResponse
|
||||
|
||||
export const zListTagsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2509,10 +2641,10 @@ export const zUpdateMultipleSettingsData = z.object({
|
||||
*/
|
||||
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetSettingByKeyData = z.object({
|
||||
export const zGetSettingByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2520,14 +2652,14 @@ export const zGetSettingByKeyData = z.object({
|
||||
/**
|
||||
* Setting value response
|
||||
*/
|
||||
export const zGetSettingByKeyResponse = z.object({
|
||||
export const zGetSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const zUpdateSettingByKeyData = z.object({
|
||||
export const zUpdateSettingByIdData = z.object({
|
||||
body: z.unknown(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2535,7 +2667,7 @@ export const zUpdateSettingByKeyData = z.object({
|
||||
/**
|
||||
* Updated setting value response
|
||||
*/
|
||||
export const zUpdateSettingByKeyResponse = z.object({
|
||||
export const zUpdateSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
@@ -2691,21 +2823,7 @@ export const zUploadMaskData = z.object({
|
||||
export const zUploadMaskResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
is_mask: z.boolean().optional(),
|
||||
original_hash: z.string().optional(),
|
||||
mask_type: z.string().optional(),
|
||||
related_files: z
|
||||
.object({
|
||||
mask: z.string().optional(),
|
||||
paint: z.string().optional(),
|
||||
painted: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
type: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetLogsData = z.object({
|
||||
@@ -2774,6 +2892,115 @@ export const zGetJwksData = z.object({
|
||||
*/
|
||||
export const zGetJwksResponse = zJwksResponse
|
||||
|
||||
export const zGetOAuthAuthorizationServerData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Authorization-server metadata
|
||||
*/
|
||||
export const zGetOAuthAuthorizationServerResponse =
|
||||
zOAuthAuthorizationServerMetadata
|
||||
|
||||
export const zGetOAuthProtectedResourceData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
export const zGetOAuthProtectedResourceResponse =
|
||||
zOAuthProtectedResourceMetadata
|
||||
|
||||
export const zGetOAuthProtectedResourceByPathData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
resourcePath: z.string().regex(/^[a-zA-Z0-9._-]+$/)
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
export const zGetOAuthProtectedResourceByPathResponse =
|
||||
zOAuthProtectedResourceMetadata
|
||||
|
||||
export const zGetOAuthAuthorizeData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
response_type: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.string().optional(),
|
||||
resource: z.string().optional(),
|
||||
oauth_request_id: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
|
||||
*
|
||||
*/
|
||||
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
|
||||
|
||||
export const zPostOAuthAuthorizeData = z.object({
|
||||
body: z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
workspace_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
|
||||
*/
|
||||
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
|
||||
|
||||
export const zPostOAuthTokenData = z.object({
|
||||
body: z.object({
|
||||
grant_type: z.enum(['authorization_code', 'refresh_token']),
|
||||
client_id: z.string(),
|
||||
code: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
client_secret: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* New token pair
|
||||
*/
|
||||
export const zPostOAuthTokenResponse = zOAuthTokenResponse
|
||||
|
||||
export const zPostOAuthRegisterData = z.object({
|
||||
body: zOAuthRegisterRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
|
||||
*/
|
||||
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
|
||||
|
||||
export const zListWorkspacesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3078,6 +3305,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Config inserted successfully
|
||||
*/
|
||||
export const zInsertDynamicConfigResponse = z.object({
|
||||
id: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
export const zSyncApiKeyData = z.object({
|
||||
body: zSyncApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3671,12 +3920,6 @@ export const zGetHealthData = z.object({
|
||||
*/
|
||||
export const zGetHealthResponse = z.string()
|
||||
|
||||
export const zGetOpenapiSpecData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetMonitoringTasksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3757,6 +4000,16 @@ export const zPostCustomNodeProxyData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -3832,3 +4085,150 @@ export const zGetLegacyViewMetadataData = z.object({
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetEmbeddingsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Embedding names
|
||||
*/
|
||||
export const zGetEmbeddingsResponse = z.array(z.string())
|
||||
|
||||
export const zFreeMemoryData = z.object({
|
||||
body: z
|
||||
.object({
|
||||
unload_models: z.boolean().optional(),
|
||||
free_memory: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetI18nData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Nested map of locale to translation key-value pairs
|
||||
*/
|
||||
export const zGetI18nResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetInternalFolderPathsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Map of folder type name to list of path entries
|
||||
*/
|
||||
export const zGetInternalFolderPathsResponse = z.record(
|
||||
z.array(z.array(z.string()))
|
||||
)
|
||||
|
||||
export const zGetInternalLogsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Log text
|
||||
*/
|
||||
export const zGetInternalLogsResponse = z.string()
|
||||
|
||||
export const zGetInternalLogsRawData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Structured log data
|
||||
*/
|
||||
export const zGetInternalLogsRawResponse = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
t: z.number().optional(),
|
||||
m: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
size: z
|
||||
.object({
|
||||
cols: z.number().int().optional(),
|
||||
rows: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zSubscribeToLogsData = z.object({
|
||||
body: z.object({
|
||||
clientId: z.string(),
|
||||
enabled: z.boolean()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPruneAssetsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Prune result
|
||||
*/
|
||||
export const zPruneAssetsResponse = z.object({
|
||||
status: z.string().optional(),
|
||||
marked: z.number().int().optional()
|
||||
})
|
||||
|
||||
export const zSeedAssetsData = z.object({
|
||||
body: z
|
||||
.object({
|
||||
roots: z.array(z.string()).optional()
|
||||
})
|
||||
.optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Seed started
|
||||
*/
|
||||
export const zSeedAssetsResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetAssetSeedStatusData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Scan progress details (files scanned, total, status, etc.)
|
||||
*/
|
||||
export const zGetAssetSeedStatusResponse = z.record(z.unknown())
|
||||
|
||||
export const zCancelAssetSeedData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Scan cancelled
|
||||
*/
|
||||
export const zCancelAssetSeedResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
@@ -46,7 +46,10 @@
|
||||
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div @contextmenu.capture="clearContextMenuTarget">
|
||||
<div
|
||||
class="min-w-0 overflow-x-hidden"
|
||||
@contextmenu.capture="clearContextMenuTarget"
|
||||
>
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
v-model:expanded-rows="expandedRows"
|
||||
@@ -60,6 +63,7 @@
|
||||
selection-mode="single"
|
||||
context-menu
|
||||
striped-rows
|
||||
:table-style="{ tableLayout: 'fixed', width: '100%' }"
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@@ -71,12 +75,11 @@
|
||||
field="id"
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
class="flex items-center gap-1 truncate"
|
||||
class="flex min-w-0 items-center gap-1 truncate"
|
||||
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
@@ -103,53 +106,38 @@
|
||||
<Column
|
||||
field="keybindings"
|
||||
:header="$t('g.keybinding')"
|
||||
:style="{ width: '30%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
v-if="slotProps.data.keybindings.length > 0"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<template
|
||||
v-for="(binding, idx) in (
|
||||
slotProps.data as ICommandData
|
||||
).keybindings.slice(0, 2)"
|
||||
:key="binding.combo.serialize()"
|
||||
>
|
||||
<span v-if="idx > 0" class="text-muted-foreground">,</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="slotProps.data.keybindings.length > 2"
|
||||
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('g.nMoreKeybindings', {
|
||||
count: slotProps.data.keybindings.length - 2
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
<KeybindingList
|
||||
:keybindings="slotProps.data.keybindings"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:style="{ width: '16%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
<span class="block truncate" :title="slotProps.data.source">{{
|
||||
slotProps.data.source || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<Column
|
||||
field="actions"
|
||||
header=""
|
||||
:style="{ width: '9rem' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div class="actions flex flex-row justify-end">
|
||||
<div
|
||||
class="actions flex flex-row justify-end whitespace-nowrap"
|
||||
>
|
||||
<Button
|
||||
v-if="slotProps.data.keybindings.length === 1"
|
||||
v-tooltip="$t('g.edit')"
|
||||
@@ -330,6 +318,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeybindingList from './keybinding/KeybindingList.vue'
|
||||
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeybindingList from './KeybindingList.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
nMoreKeybindings: '+ {count} more',
|
||||
nMoreKeybindingsCompact: '+ {count}',
|
||||
keybindingListAriaLabel: 'Keybindings: {combos}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeKeybinding(key: string, ctrl = false, shift = false) {
|
||||
return new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key, ctrl, shift }
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(props: {
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}) {
|
||||
return render(KeybindingList, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('KeybindingList', () => {
|
||||
it('renders "-" placeholder when there are no keybindings', () => {
|
||||
renderList({ keybindings: [] })
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a single keybinding without any "more" badge', () => {
|
||||
renderList({ keybindings: [makeKeybinding('A', true)] })
|
||||
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-medium')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-compact')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 1')
|
||||
})
|
||||
|
||||
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
|
||||
renderList({
|
||||
keybindings: [
|
||||
makeKeybinding('A', true),
|
||||
makeKeybinding('B', true),
|
||||
makeKeybinding('C', true)
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 2 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 2')
|
||||
})
|
||||
|
||||
it('uses a container query parent so the visible tier can adapt to width', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
expect(screen.getByTestId('keybinding-list').className).toContain(
|
||||
'@container/keybindings'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits an accessible label listing all combos', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
|
||||
})
|
||||
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
|
||||
expect(ariaText).toContain('Keybindings:')
|
||||
expect(ariaText).toContain('Ctrl')
|
||||
expect(ariaText).toContain('A')
|
||||
expect(ariaText).toContain('Shift')
|
||||
expect(ariaText).toContain('B')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="keybindings.length > 0"
|
||||
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
|
||||
data-testid="keybinding-list"
|
||||
>
|
||||
<KeyComboDisplay
|
||||
:key-combo="keybindings[0].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
<template v-if="keybindings.length >= 2">
|
||||
<span
|
||||
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
|
||||
aria-hidden="true"
|
||||
>
|
||||
,
|
||||
</span>
|
||||
<KeyComboDisplay
|
||||
class="hidden @[16rem]/keybindings:inline-flex"
|
||||
:key-combo="keybindings[1].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="keybindings.length > 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
|
||||
data-testid="keybinding-list-more-wide"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-medium"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-compact"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span class="sr-only" data-testid="keybinding-list-aria">
|
||||
{{ ariaLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeyComboDisplay from './KeyComboDisplay.vue'
|
||||
|
||||
const { keybindings, isModified = false } = defineProps<{
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const ariaLabel = computed(() => {
|
||||
if (keybindings.length === 0) return ''
|
||||
const combos = keybindings
|
||||
.map((binding) => binding.combo.toString())
|
||||
.join(', ')
|
||||
return t('g.keybindingListAriaLabel', { combos })
|
||||
})
|
||||
</script>
|
||||
@@ -93,16 +93,7 @@ describe('TabErrors.vue', () => {
|
||||
refreshing: 'Refreshing missing models.'
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaTitle: 'Missing Inputs',
|
||||
image: 'Images',
|
||||
uploadFile: 'Upload {type}',
|
||||
useFromLibrary: 'Use from Library',
|
||||
confirmSelection: 'Confirm selection',
|
||||
locateNode: 'Locate node',
|
||||
expandNodes: 'Show referencing nodes',
|
||||
collapseNodes: 'Hide referencing nodes',
|
||||
cancelSelection: 'Cancel selection',
|
||||
or: 'OR'
|
||||
missingMediaTitle: 'Missing Inputs'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,6 +459,50 @@ describe('TabErrors.vue', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one missing media item per referencing node and locates the selected node', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
const titles: Record<string, string> = {
|
||||
'3': 'First Loader',
|
||||
'4': 'Second Loader'
|
||||
}
|
||||
return {
|
||||
title: titles[String(nodeId)] ?? ''
|
||||
} as ReturnType<typeof getNodeByExecutionId>
|
||||
})
|
||||
|
||||
const { user } = renderComponent({
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'shared.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '4',
|
||||
nodeType: 'PreviewImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'shared.png',
|
||||
isMissing: true
|
||||
}
|
||||
] satisfies MissingMediaCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
)
|
||||
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
|
||||
@@ -256,7 +256,6 @@
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -131,6 +131,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
|
||||
function makeMissingNodeType(
|
||||
type: string,
|
||||
@@ -178,6 +179,24 @@ function makeModel(
|
||||
}
|
||||
}
|
||||
|
||||
function makeMedia(
|
||||
name: string,
|
||||
opts: {
|
||||
nodeId: string | number
|
||||
nodeType?: string
|
||||
widgetName?: string
|
||||
}
|
||||
): MissingMediaCandidate {
|
||||
return {
|
||||
name,
|
||||
nodeId: opts.nodeId,
|
||||
nodeType: opts.nodeType ?? 'LoadImage',
|
||||
widgetName: opts.widgetName ?? 'image',
|
||||
mediaType: 'image',
|
||||
isMissing: true
|
||||
}
|
||||
}
|
||||
|
||||
function createErrorGroups() {
|
||||
const store = useExecutionErrorStore()
|
||||
const searchQuery = ref('')
|
||||
@@ -1060,6 +1079,27 @@ describe('useErrorGroups', () => {
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
})
|
||||
|
||||
it('counts missing media by affected node rows, not grouped filenames', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingMedia([
|
||||
makeMedia('shared.png', { nodeId: '1', nodeType: 'LoadImage' }),
|
||||
makeMedia('shared.png', { nodeId: '2', nodeType: 'PreviewImage' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(store.totalErrorCount).toBe(2)
|
||||
expect(groups.missingMediaGroups.value).toHaveLength(1)
|
||||
expect(groups.missingMediaGroups.value[0].items).toHaveLength(1)
|
||||
expect(
|
||||
groups.missingMediaGroups.value[0].items[0].referencingNodes
|
||||
).toHaveLength(2)
|
||||
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabErrorGroups', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import {
|
||||
resolveMissingErrorMessage,
|
||||
resolveRunErrorMessage
|
||||
@@ -690,10 +691,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function buildMissingMediaGroups(): ErrorGroup[] {
|
||||
if (!missingMediaGroups.value.length) return []
|
||||
const totalItems = missingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
)
|
||||
const totalRows = countMissingMediaReferences(missingMediaGroups.value)
|
||||
return [
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
@@ -702,7 +700,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: missingMediaGroups.value,
|
||||
count: totalItems,
|
||||
count: totalRows,
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
@@ -806,9 +804,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingMediaGroups.value.length) return []
|
||||
const totalItems = filteredMissingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
const totalRows = countMissingMediaReferences(
|
||||
filteredMissingMediaGroups.value
|
||||
)
|
||||
return [
|
||||
{
|
||||
@@ -818,7 +815,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: filteredMissingMediaGroups.value,
|
||||
count: totalItems,
|
||||
count: totalRows,
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user