feat(website): implement download/local page

- Add HeroSection, CloudBannerSection, ReasonSection, EcoSystemSection,
  ProductCardsSection, FAQSection components
- Add ProductHeroBadge with two-size node badge variant
- Extend NodeBadge with per-segment class and configurable union icon
- Add useDownloadUrl composable for OS-aware download links
- Add all en/zh-CN translations for the download page
- Replace zh-CN hardcoded download page with shared components
- Add icon-mask utility for CSS mask-based icons
- Add Playwright e2e tests (smoke, interaction, mobile)
- FAQ uses independent expand/collapse (all open by default)
- Sticky headings in ReasonSection and FAQSection on all viewports
This commit is contained in:
Yourz
2026-04-14 09:46:26 +08:00
parent 3dd08cf038
commit 892d4669af
20 changed files with 1032 additions and 91 deletions

View File

@@ -0,0 +1,163 @@
import { expect, test } from '@playwright/test'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', 'https://app.comfy.org')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
})
test('HeroSection has download and GitHub buttons', async ({ page }) => {
const downloadBtn = page
.getByRole('link', { name: /DOWNLOAD LOCAL/i })
.first()
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
const githubBtn = page
.getByRole('link', { name: /INSTALL FROM GITHUB/i })
.first()
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
)
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Unlimited',
'Any model',
'Your machine',
'Free. Open Source'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('EcoSystemSection heading is visible', async ({ page }) => {
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('ProductCardsSection links to cloud, api, enterprise', async ({
page
}) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
test('FAQSection heading is visible with 8 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(8)
})
})
test.describe('FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('all FAQs are expanded by default', async ({ page }) => {
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeVisible()
})
test('clicking an expanded FAQ collapses it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
})
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
})
})
test.describe('Download page mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('CloudBannerSection is visible', async ({ page }) => {
await expect(page.getByText(/Need more power/)).toBeVisible()
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
})
test('download buttons are stacked vertically', async ({ page }) => {
const downloadBtn = page
.getByRole('link', { name: /DOWNLOAD LOCAL/i })
.first()
const githubBtn = page
.getByRole('link', { name: /INSTALL FROM GITHUB/i })
.first()
await downloadBtn.scrollIntoViewIfNeeded()
const downloadBox = await downloadBtn.boundingBox()
const githubBox = await githubBtn.boundingBox()
expect(downloadBox, 'download button bounding box').not.toBeNull()
expect(githubBox, 'github button bounding box').not.toBeNull()
expect(githubBox!.y).toBeGreaterThan(downloadBox!.y)
})
})

View File

@@ -0,0 +1,3 @@
<svg width="62" height="94.14" viewBox="0 0 62 80" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.9346 0C33.456 0.000149153 39.6242 8.20368 36.7305 18.3115L33.9385 28.0635C32.7454 32.2159 35.8674 36.3555 40.1826 36.3555C42.9814 36.3555 45.4493 34.5653 46.3311 31.9268L47.7129 27.002C49.4225 20.9287 55.812 16 62 16V64H48.5342C42.3461 64 38.7182 59.0713 40.4199 52.998L40.8398 51.5L40.8301 51.4922C42.0104 47.3146 38.8756 43.1751 34.5352 43.1748C31.6287 43.1748 29.0515 45.1048 28.252 47.9111L24.3047 61.6885H24.2793C21.3855 71.7964 10.5089 80 0 80V0H22.9346Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FAQSection from './FAQSection.vue'
const meta: Meta<typeof FAQSection> = {
title: 'Website/Common/FAQSection',
component: FAQSection,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-8"><story /></div>'
})
],
args: {
heading: 'FAQ',
items: [
{
question: 'What hardware do I need to run ComfyUI?',
answer:
'A dedicated GPU is strongly recommended. NVIDIA GPUs with at least 4GB VRAM work best, but AMD and Apple Silicon are also supported.'
},
{
question: 'Is ComfyUI free?',
answer:
'Yes, ComfyUI is completely free and open source. You can run it on your own hardware at no cost.'
},
{
question: 'Can I use ComfyUI commercially?',
answer:
'Yes. ComfyUI is released under the GPL license, so you are free to use it for commercial purposes.'
}
]
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const SingleItem: Story = {
args: {
heading: 'Questions',
items: [
{
question: 'How do I get started?',
answer: 'Download ComfyUI and follow the setup guide.'
}
]
}
}
export const ManyItems: Story = {
args: {
heading: 'Frequently Asked Questions',
items: Array.from({ length: 8 }, (_, i) => ({
question: `Question ${i + 1}: What about feature ${i + 1}?`,
answer: `This is the detailed answer for question ${i + 1}. It explains everything you need to know about this particular topic.`
}))
}
}

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { reactive } from 'vue'
interface FAQItem {
question: string
answer: string
}
const { heading, items } = defineProps<{
heading: string
items: FAQItem[]
}>()
const expanded = reactive(items.map(() => true))
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="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 items"
:key="index"
class="border-primary-comfy-canvas/20 border-b"
>
<button
:aria-expanded="expanded[index]"
:aria-controls="`faq-panel-${index}`"
class="flex w-full cursor-pointer items-center justify-between text-left"
:class="index === 0 ? 'pb-6' : 'py-6'"
@click="toggle(index)"
>
<span
class="text-lg font-light md:text-xl"
:class="
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>
<div
v-if="expanded[index]"
:id="`faq-panel-${index}`"
role="region"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm">
{{ faq.answer }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -8,8 +8,7 @@ const meta: Meta<typeof NodeBadge> = {
tags: ['autodocs'],
decorators: [
() => ({
template:
'<div class="bg-primary-comfy-ink p-8"><story /></div>'
template: '<div class="bg-primary-comfy-ink p-8"><story /></div>'
})
]
}
@@ -32,8 +31,30 @@ export const MultipleSegments: Story = {
export const WithLogo: Story = {
args: {
segments: [
{ logoSrc: '/logos/comfy-logo.svg', logoAlt: 'Comfy Logo' },
{ logoSrc: '/icons/logo.svg', logoAlt: 'Comfy' },
{ text: 'NODES' }
]
}
}
export const ComfyLocal: Story = {
args: {
segments: [
{ logoSrc: '/icons/logo.svg', logoAlt: 'Comfy' },
{ text: 'LOCAL' }
]
}
}
export const WithCustomSegmentClass: Story = {
args: {
segments: [
{
logoSrc: '/icons/logo.svg',
logoAlt: 'Comfy',
class: 'lg:py-8 lg:px-10'
},
{ text: 'LOCAL' }
]
}
}

View File

@@ -1,7 +1,19 @@
<script setup lang="ts">
const { segments, segmentClass = 'px-6' } = defineProps<{
segments: Array<{ text?: string; logoSrc?: string; logoAlt?: string }>
export interface NodeBadgeSegment {
text?: string
logoSrc?: string
logoAlt?: string
class?: string
}
const {
segments,
segmentClass = 'px-6',
unionSrc = '/icons/node-union.svg'
} = defineProps<{
segments: NodeBadgeSegment[]
segmentClass?: string
unionSrc?: string
}>()
</script>
@@ -19,14 +31,14 @@ const { segments, segmentClass = 'px-6' } = defineProps<{
<template v-for="(segment, i) in segments" :key="i">
<img
v-if="i > 0"
src="/icons/node-union.svg"
:src="unionSrc"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center justify-center py-1 lg:py-5"
:class="segmentClass"
:class="[segmentClass, segment.class]"
>
<img
v-if="segment.logoSrc"

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
const {
logoSrc = '/icons/logo.svg',
logoAlt = 'Comfy',
text = 'LOCAL'
} = defineProps<{
logoSrc?: string
logoAlt?: string
text?: string
}>()
</script>
<template>
<div class="font-formula-condensed flex items-stretch font-semibold">
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
>
<img
:src="logoSrc"
:alt="logoAlt"
class="inline-block h-6 brightness-0 lg:h-10"
/>
</span>
<img
src="/icons/node-union-2size.svg"
alt=""
class="-mx-px my-auto h-12 self-center lg:my-0 lg:h-auto lg:self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
>
<span
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
>
{{ text }}
</span>
</span>
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px my-auto h-7.25 self-center lg:h-15.5"
aria-hidden="true"
/>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="bg-transparency-white-t4 px-4 pt-28 pb-4 text-center lg:px-20 lg:pt-36 lg:pb-8"
>
<p
class="text-primary-comfy-canvas text-lg font-semibold lg:text-sm lg:font-normal"
>
{{ t('download.cloud.prefix', locale) }}
<a
:href="externalLinks.app"
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest uppercase hover:underline"
>
{{ t('download.cloud.cta', locale) }}
</a>
<span class="mt-1 block lg:mt-0 lg:inline">
{{ t('download.cloud.suffix', locale) }}
</span>
</p>
</section>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { downloadUrl } = useDownloadUrl()
</script>
<template>
<section class="px-4 py-24 md:px-20 md:py-40">
<div
class="flex flex-col-reverse items-stretch gap-10 md:flex-row md:gap-16"
>
<!-- Text content -->
<div class="flex flex-1 flex-col justify-between">
<div>
<h2 class="text-primary-comfy-canvas text-3xl font-light md:text-4xl">
{{ t('download.ecosystem.heading', locale) }}
</h2>
<p class="text-primary-comfy-canvas/70 mt-6 text-sm">
{{ t('download.ecosystem.description', locale) }}
</p>
</div>
<!-- CTA buttons -->
<div class="mt-10 flex flex-col gap-4 md:flex-row">
<a
:href="downloadUrl"
target="_blank"
rel="noopener"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-full px-8 py-4 text-center text-sm font-bold tracking-wider transition-opacity hover:opacity-90"
>
{{ t('download.hero.downloadLocal', locale) }}
</a>
<a
:href="externalLinks.github"
target="_blank"
rel="noopener"
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink flex items-center justify-center gap-2 rounded-full border px-8 py-4 text-sm font-bold tracking-wider transition-colors"
>
<span
class="icon-mask size-5 mask-[url('/icons/social/github.svg')]"
aria-hidden="true"
/>
{{ t('download.hero.installGithub', locale) }}
</a>
</div>
</div>
<!-- TODO: Replace with final ecosystem illustration -->
<div class="flex-1">
<div
class="aspect-4/3 w-full overflow-hidden rounded-3xl bg-linear-to-b from-emerald-300 to-amber-200"
>
<div class="flex h-full items-center justify-center">
<span
class="bg-primary-comfy-ink text-primary-comfy-yellow rounded-full px-4 py-2 text-sm font-bold"
>
4x
</span>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
import SharedFAQSection from '../../common/FAQSection.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqKeys: Array<{ q: TranslationKey; a: TranslationKey }> = Array.from(
{ length: 8 },
(_, i) => ({
q: `download.faq.${i + 1}.q` as TranslationKey,
a: `download.faq.${i + 1}.a` as TranslationKey
})
)
const items = faqKeys.map(({ q, a }) => ({
question: t(q, locale),
answer: t(a, locale)
}))
</script>
<template>
<SharedFAQSection
:heading="t('download.faq.heading', locale)"
:items="items"
/>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { useDownloadUrl } from '../../../composables/useDownloadUrl'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import ProductHeroBadge from '../../common/ProductHeroBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { downloadUrl } = useDownloadUrl()
</script>
<template>
<section class="overflow-hidden px-4 pt-20 pb-16 md:px-20 md:py-24">
<div class="mx-auto flex max-w-5xl flex-col items-center">
<div class="flex w-full max-w-2xl flex-col items-center text-center">
<ProductHeroBadge />
<h1
class="text-primary-comfy-canvas mt-8 max-w-[10ch] text-5xl/tight font-light whitespace-pre-line md:max-w-none md:text-5xl"
>
{{ t('download.hero.heading', locale) }}
</h1>
<p
class="text-primary-comfy-canvas mt-8 max-w-xs text-sm md:mt-20 md:max-w-xl md:text-base"
>
{{ t('download.hero.subtitle', locale) }}
</p>
<div
class="mt-10 flex w-full max-w-md flex-col gap-4 md:w-auto md:max-w-none md:flex-row"
>
<a
:href="downloadUrl"
target="_blank"
rel="noopener"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-full px-8 py-4 text-center text-sm font-bold tracking-wider transition-opacity hover:opacity-90 md:min-w-60"
>
{{ t('download.hero.downloadLocal', locale) }}
</a>
<a
:href="externalLinks.github"
class="border-primary-comfy-yellow text-primary-comfy-yellow hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink flex items-center justify-center gap-2 rounded-full border px-8 py-4 text-sm font-bold tracking-wider transition-colors md:min-w-60"
>
<span
class="icon-mask size-5 mask-[url('/icons/social/github.svg')]"
aria-hidden="true"
/>
{{ t('download.hero.installGithub', locale) }}
</a>
</div>
</div>
<!-- Placeholder for future animation; clipped within hero section -->
<div
class="relative mt-4 flex h-104 w-full max-w-4xl items-start justify-center overflow-hidden md:mt-12 md:h-136 md:items-center"
>
<div
class="border-secondary-mauve/35 bg-primary-comfy-ink/35 absolute top-20 left-2 h-64 w-44 rotate-30 rounded-4xl border"
/>
<div
class="border-secondary-mauve/35 bg-primary-comfy-ink/35 absolute top-28 left-20 h-56 w-40 rotate-30 rounded-4xl border"
/>
<div
class="border-secondary-mauve/35 bg-primary-comfy-ink/35 absolute top-52 right-4 h-56 w-40 rotate-30 rounded-4xl border"
/>
<div class="relative z-10 mt-28 grid grid-cols-3 gap-0.5 md:mt-0">
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-primary-comfy-plum block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-primary-comfy-plum block size-9 rounded-lg" />
<span class="bg-primary-comfy-yellow block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
<span class="bg-primary-comfy-plum block size-9 rounded-lg" />
<span class="bg-secondary-mauve block size-9 rounded-lg" />
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { getRoutes } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import ProductCard from '../../common/ProductCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
const cards = [
{
title: t('products.cloud.title', locale),
description: t('products.cloud.description', locale),
cta: t('products.cloud.cta', locale),
href: routes.cloud,
bg: 'bg-secondary-mauve'
},
{
title: t('products.api.title', locale),
description: t('products.api.description', locale),
cta: t('products.api.cta', locale),
href: routes.api,
bg: 'bg-primary-comfy-plum'
},
{
title: t('products.enterprise.title', locale),
description: t('products.enterprise.description', locale),
cta: t('products.enterprise.cta', locale),
href: routes.cloudEnterprise,
bg: 'bg-illustration-forest'
}
]
</script>
<template>
<section class="bg-primary-comfy-ink px-4 py-24 md:px-20 md:py-40">
<!-- Header -->
<div class="flex flex-col items-center text-center">
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ t('products.label', locale) }}
</p>
<h2
class="text-primary-comfy-canvas mt-4 text-4xl font-light whitespace-pre-line md:text-5xl"
>
{{ t('products.heading', locale) }}
</h2>
<p class="text-primary-comfy-canvas/70 mt-4 text-sm">
{{ t('products.subheading', locale) }}
</p>
</div>
<!-- Cards -->
<div class="mt-16 grid grid-cols-1 gap-4 md:grid-cols-3">
<ProductCard v-for="card in cards" :key="card.title" v-bind="card" />
</div>
</section>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
import SharedReasonSection from '../shared/ReasonSection.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasons = [
{
title: t('download.reason.1.title', locale),
description: t('download.reason.1.description', locale)
},
{
title: t('download.reason.2.title', locale),
description: t('download.reason.2.description', locale)
},
{
title: t('download.reason.3.title', locale),
description: t('download.reason.3.description', locale)
},
{
title: t('download.reason.4.title', locale),
description: t('download.reason.4.description', locale)
}
]
</script>
<template>
<SharedReasonSection
:heading="t('download.reason.heading', locale)"
:heading-highlight="t('download.reason.headingHighlight', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ReasonSection from './ReasonSection.vue'
const meta: Meta<typeof ReasonSection> = {
title: 'Website/Product/ReasonSection',
component: ReasonSection,
tags: ['autodocs'],
decorators: [
() => ({
template: '<div class="bg-primary-comfy-ink p-8"><story /></div>'
})
],
args: {
heading: 'Why professionals\nchoose ',
headingHighlight: 'Comfy Local',
reasons: [
{
title: 'Unlimited\ncreative power',
description:
'Run any workflow without limits. No queues, no credits, no restrictions on what you can create.'
},
{
title: 'Any model,\nany time',
description:
'Use any open-source model instantly. Switch between Stable Diffusion, Flux, and more with a single click.'
},
{
title: 'Your machine,\nyour rules',
description:
'Your data never leaves your computer. Full privacy and complete control over your creative environment.'
},
{
title: 'Free.\nOpen Source.',
description:
'No subscriptions, no hidden fees. ComfyUI is and always will be free and open source.'
}
]
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithoutHighlight: Story = {
args: {
heading: 'Why choose Comfy',
headingHighlight: '',
reasons: [
{
title: 'Fast',
description: 'Optimized for speed and efficiency.'
},
{
title: 'Flexible',
description: 'Adapt to any workflow with ease.'
}
]
}
}

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
interface Reason {
title: string
description: string
}
const {
heading,
headingHighlight = '',
reasons
} = defineProps<{
heading: string
headingHighlight?: string
reasons: Reason[]
}>()
</script>
<template>
<section
class="flex flex-col gap-4 px-4 py-24 md:flex-row md:gap-16 md:px-20 md:py-40"
>
<!-- 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-115 md:py-0"
>
<h2
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line md:text-5xl"
>
{{ heading
}}<span v-if="headingHighlight" class="text-primary-warm-white">{{
headingHighlight
}}</span>
</h2>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="reason in reasons"
:key="reason.title"
class="border-primary-comfy-canvas/20 flex flex-col gap-4 border-b py-10 first:pt-0 md:flex-row md:gap-12"
>
<h3
class="text-primary-comfy-canvas shrink-0 text-2xl font-light whitespace-pre-line md:w-52"
>
{{ reason.title }}
</h3>
<p class="text-primary-comfy-canvas/70 flex-1 text-sm">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,15 @@
const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
mac: 'https://download.comfy.org/mac/dmg/arm64'
} as const
function getDownloadUrl(): string {
if (typeof navigator === 'undefined') return downloadUrls.windows
const ua = navigator.userAgent.toLowerCase()
if (ua.includes('macintosh')) return downloadUrls.mac
return downloadUrls.windows
}
export function useDownloadUrl() {
return { downloadUrl: getDownloadUrl() }
}

View File

@@ -218,6 +218,171 @@ const translations = {
en: "Comfy gives you the building blocks to create workflows nobody's imagined yet — and share them with everyone.",
'zh-CN': 'Comfy 为您提供构建模块,创造出前所未有的工作流——并与所有人分享。'
},
// Download FAQSection
'download.faq.heading': {
en: "FAQ's",
'zh-CN': '常见问题'
},
'download.faq.1.q': {
en: 'Do I need a GPU to run ComfyUI locally?',
'zh-CN': '本地运行 ComfyUI 需要 GPU 吗?'
},
'download.faq.1.a': {
en: 'A dedicated GPU is strongly recommended — more VRAM means bigger models and batches. No GPU? Run the same workflow on Comfy Cloud.',
'zh-CN':
'强烈建议使用独立 GPU——更大的显存意味着更大的模型和批量。没有 GPU在 Comfy Cloud 上运行相同的工作流。'
},
'download.faq.2.q': {
en: 'How much disk space do I need?',
'zh-CN': '需要多少磁盘空间?'
},
'download.faq.2.a': {
en: 'ComfyUI is lightweight, models are the heavy part. Plan for a dedicated drive as your library grows.',
'zh-CN':
'ComfyUI 本身很轻量,模型才是大头。随着库的增长,建议准备专用硬盘。'
},
'download.faq.3.q': {
en: "Is it really free? What's the catch?",
'zh-CN': '真的免费吗?有什么附加条件?'
},
'download.faq.3.a': {
en: 'Yes. Free and open source under GPL-3.0. No feature gates, no trials, no catch.',
'zh-CN':
'是的。基于 GPL-3.0 免费开源。没有功能限制、没有试用期、没有附加条件。'
},
'download.faq.4.q': {
en: 'Why would I pay for Comfy Cloud if Local is free?',
'zh-CN': '既然本地版免费,为什么还要付费使用 Comfy Cloud'
},
'download.faq.4.a': {
en: 'Your machine or ours. Cloud gives you powerful GPUs on demand, pre-loaded models, end-to-end security and infrastructure out of the box and partner models cleared for commercial use.',
'zh-CN':
'你的机器或我们的。Cloud 按需提供强大 GPU、预加载模型、端到端安全性和开箱即用的基础设施以及经过商业许可的合作伙伴模型。'
},
'download.faq.5.q': {
en: "What's the difference between Desktop, Portable, and CLI install?",
'zh-CN': 'Desktop、Portable 和 CLI 安装有什么区别?'
},
'download.faq.5.a': {
en: 'Desktop: one-click installer with auto-updates. Portable: self-contained build you can run from any folder. CLI: clone from GitHub for full developer control, for developers who want to customize the environment or contribute upstream.',
'zh-CN':
'Desktop一键安装自动更新。Portable独立构建可从任意文件夹运行。CLI从 GitHub 克隆,完全开发者控制,适合想自定义环境或参与上游贡献的开发者。'
},
'download.faq.6.q': {
en: 'Can I use my local workflows in Comfy Cloud?',
'zh-CN': '我可以在 Comfy Cloud 中使用本地工作流吗?'
},
'download.faq.6.a': {
en: 'Yes — same file, same results. No conversion, no rework.',
'zh-CN': '可以——同样的文件,同样的结果。无需转换,无需返工。'
},
'download.faq.7.q': {
en: 'How do I install custom nodes and extensions?',
'zh-CN': '如何安装自定义节点和扩展?'
},
'download.faq.7.a': {
en: 'ComfyUI Manager lets you browse, install, update, and manage 5,000+ extensions from inside the app.',
'zh-CN': 'ComfyUI Manager 让你在应用内浏览、安装、更新和管理 5,000+ 扩展。'
},
'download.faq.8.q': {
en: 'My workflow is running slowly. Should I switch to Cloud?',
'zh-CN': '我的工作流运行缓慢。应该切换到 Cloud 吗?'
},
'download.faq.8.a': {
en: 'No need to switch. Push heavy jobs to Comfy Cloud when you need more compute, keep building locally the rest of the time.',
'zh-CN':
'无需切换。需要更多算力时将繁重任务推送到 Comfy Cloud其余时间继续在本地构建。'
},
// Download EcoSystemSection
'download.ecosystem.heading': {
en: 'An ecosystem that moves faster than any company could.',
'zh-CN': '一个比任何公司都迭代更快的生态系统。'
},
'download.ecosystem.description': {
en: 'Over 5,000 community-built extensions — totaling 60,000+ nodes — plug into ComfyUI and extend what it can do. When a new open model launches, ComfyUI implements it, and the community customizes and builds it into their workflows immediately. When a research paper drops a new technique, an extension appears within days.',
'zh-CN':
'超过 5,000 个社区构建的扩展——共计 60,000+ 节点——接入 ComfyUI 并扩展其能力。当新的开源模型发布时ComfyUI 会实现它,社区会立即将其定制并构建到工作流中。当研究论文发布新技术时,几天内就会出现相应扩展。'
},
// Download ReasonSection
'download.reason.heading': {
en: 'Why\nprofessionals\nchoose ',
'zh-CN': '专业人士为何\n选择'
},
'download.reason.headingHighlight': {
en: 'Local',
'zh-CN': '本地版'
},
'download.reason.1.title': {
en: 'Unlimited\nCustomization',
'zh-CN': '无限\n自定义'
},
'download.reason.1.description': {
en: 'Install any of 5,000+ community extensions, totaling 60,000+ nodes. Build your own custom nodes. Integrate with Photoshop, Nuke, Blender, Houdini, and any tool in your existing pipeline.',
'zh-CN':
'安装 5,000+ 社区扩展中的任何一个,共计 60,000+ 节点。构建自定义节点。与 Photoshop、Nuke、Blender、Houdini 及现有管线中的任何工具集成。'
},
'download.reason.2.title': {
en: 'Any model.\nNo exceptions.',
'zh-CN': '任何模型。\n无一例外。'
},
'download.reason.2.description': {
en: 'Run every open-source model — Wan 2.1, Flux, LTX and more. Finetune, customize, control the full inference process. Or use partner models like Nano Banana and Grok.',
'zh-CN':
'运行每个开源模型——Wan 2.1、Flux、LTX 等。微调、自定义、控制完整推理过程。或使用 Nano Banana 和 Grok 等合作伙伴模型。'
},
'download.reason.3.title': {
en: 'Your machine.\nYour data.\nYour terms.',
'zh-CN': '你的机器。\n你的数据。\n你的规则。'
},
'download.reason.3.description': {
en: 'Run entirely offline. No internet connection required after setup. Your workflows, your models, your data.',
'zh-CN':
'完全离线运行。安装后无需网络连接。你的工作流、你的模型、你的数据。'
},
'download.reason.4.title': {
en: 'Free. Open Source.\nNo ceiling.',
'zh-CN': '免费。开源。\n没有上限。'
},
'download.reason.4.description': {
en: 'No feature gates, no trial periods, no "pro" tier for core functionality. No vendor can lock you in or force you off the platform. Build your own nodes and modify ComfyUI as your own.',
'zh-CN':
'没有功能限制、没有试用期、核心功能没有"专业"层级。没有供应商可以锁定你或强迫你离开平台。构建自己的节点,随心修改 ComfyUI。'
},
// Download HeroSection
'download.hero.heading': {
en: 'Run on your hardware.\nFree forever.',
'zh-CN': '在你的硬件上运行。\n永久免费。'
},
'download.hero.subtitle': {
en: 'The full ComfyUI engine — open source, fast, extensible, and yours to run however you want.',
'zh-CN': '完整的 ComfyUI 引擎——开源、快速、可扩展,随你运行。'
},
'download.hero.downloadLocal': {
en: 'DOWNLOAD LOCAL',
'zh-CN': '下载本地版'
},
'download.hero.installGithub': {
en: 'INSTALL FROM GITHUB',
'zh-CN': '从 GITHUB 安装'
},
// Download CloudBannerSection
'download.cloud.prefix': {
en: 'Need more power?',
'zh-CN': '需要更强算力?'
},
'download.cloud.cta': {
en: 'TRY COMFY CLOUD',
'zh-CN': '试试 COMFY CLOUD'
},
'download.cloud.suffix': {
en: 'Powerful GPUs, same workflow, same results, from anywhere.',
'zh-CN': '强大 GPU同样的工作流同样的结果随时随地。'
},
'buildWhat.row1': { en: 'BUILD WHAT', 'zh-CN': '构建' },
'buildWhat.row2a': { en: "DOESN'T EXIST", 'zh-CN': '尚不存在的' },
'buildWhat.row2b': { en: 'YET', 'zh-CN': '事物' },

View File

@@ -1,16 +1,18 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/product/local/HeroSection.vue'
import CloudBannerSection from '../components/product/local/CloudBannerSection.vue'
import ReasonSection from '../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
---
<BaseLayout title="Download Comfy — Run AI Locally">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">
Coming Soon
</h1>
<p class="text-primary-warm-gray mt-4 text-sm">
This page is being redesigned. Check back soon.
</p>
</div>
</section>
<CloudBannerSection />
<HeroSection client:load />
<ReasonSection />
<EcoSystemSection client:visible />
<ProductCardsSection />
<FAQSection client:visible />
</BaseLayout>

View File

@@ -1,80 +1,18 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import SiteNav from '../../components/common/SiteNav.vue'
import SiteFooter from '../../components/common/SiteFooter.vue'
const cards = [
{
icon: '🪟',
title: 'Windows',
description: '需要 NVIDIA 或 AMD 显卡',
cta: '下载 Windows 版',
href: 'https://download.comfy.org/windows/nsis/x64',
outlined: false,
},
{
icon: '🍎',
title: 'Mac',
description: '需要 Apple Silicon (M 系列)',
cta: '下载 Mac 版',
href: 'https://download.comfy.org/mac/dmg/arm64',
outlined: false,
},
{
icon: '🐙',
title: 'GitHub',
description: '在任何平台上从源码构建',
cta: '从 GitHub 安装',
href: 'https://github.com/comfyanonymous/ComfyUI',
outlined: true,
},
]
import CloudBannerSection from '../../components/product/local/CloudBannerSection.vue'
import HeroSection from '../../components/product/local/HeroSection.vue'
import ReasonSection from '../../components/product/local/ReasonSection.vue'
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
---
<BaseLayout title="下载 — Comfy">
<SiteNav locale="zh-CN" client:load />
<main class="mx-auto max-w-5xl px-6 py-32 text-center">
<h1 class="text-4xl font-bold text-white md:text-5xl">
下载 ComfyUI
</h1>
<p class="mt-4 text-lg text-smoke-700">
在本地体验 AI 创作
</p>
<div class="mt-16 grid grid-cols-1 gap-6 md:grid-cols-3">
{cards.map((card) => (
<a
href={card.href}
class="flex flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
>
<span class="text-4xl" aria-hidden="true">{card.icon}</span>
<h2 class="mt-4 text-xl font-semibold text-white">{card.title}</h2>
<p class="mt-2 text-sm text-smoke-700">{card.description}</p>
<span
class:list={[
'mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90',
card.outlined
? 'border border-brand-yellow text-brand-yellow'
: 'bg-brand-yellow text-black',
]}
>
{card.cta}
</span>
</a>
))}
</div>
<div class="mt-20 rounded-xl border border-white/10 bg-charcoal-800 p-8">
<p class="text-lg text-smoke-700">
没有 GPU{' '}
<a
href="https://app.comfy.org"
class="font-semibold text-brand-yellow hover:underline"
>
试试 Comfy Cloud →
</a>
</p>
</div>
</main>
<SiteFooter locale="zh-CN" />
<CloudBannerSection locale="zh-CN" />
<HeroSection locale="zh-CN" client:load />
<ReasonSection locale="zh-CN" />
<EcoSystemSection locale="zh-CN" client:visible />
<ProductCardsSection locale="zh-CN" />
<FAQSection locale="zh-CN" client:visible />
</BaseLayout>

View File

@@ -103,6 +103,13 @@
animation: marquee-reverse 30s linear infinite;
}
@utility icon-mask {
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
}
:root {
--site-bg: #211927;
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);