Compare commits

..

5 Commits

Author SHA1 Message Date
huang47
6260c101e5 test: cover queue display formatting 2026-06-24 16:54:37 -07:00
ShihChi Huang
7ab6cb57c5 test: 1/x fix coverage run (#13086)
## Summary

Fix the two current blockers that prevented `pnpm test:coverage` from
completing on `main`.

Stack order: 1/x

## Changes

- Mock `load3dAdvanced` in the lazy-loader test so coverage does not
import the real Load3DAdvanced UI graph.
- Track the active workflow status in `useWorkflowStatusDismissal` so
terminal statuses arriving after activation are cleared.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:coverage` |  failed, so the stack had no usable coverage
baseline |  passed with 877 test files passed; 11,772 passed / 8
skipped |
| focused tests | `load3dLazy` timed out; `useWorkflowStatusDismissal`
failed its active-workflow status case |  `load3dLazy`: 13 passed;
`useWorkflowStatusDismissal`: 4 passed |

## Coverage

| | before | after |
| -- | -- | -- |
| statements | unavailable | 62.84% |
| branches | unavailable | 53.03% |
| functions | unavailable | 56.94% |
| lines | unavailable | 64.05% |

Screenshots: N/A, no UI change.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 94c4c9bac1. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-06-24 23:08:29 +00:00
Alexis Rolland
3c3a2ab4e2 fix: Load Audio node not caching execution (#12950)
## Summary

This PR fixes a bug where the Load Audio node re-executes everytime.

## Changes

- **What**: Mark `audioUIWidget.options.serialize = false`

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-24 23:04:26 +00:00
Dante
a07854755f fix(billing): restore unified pricing dialog width (Reka renderer regression) (#13092)
## Summary

Restore the unified "Choose a Plan" pricing dialog width — it was
collapsing to the default `md` (576px) frame, so the 1280px table
overflowed and rendered off-center with the right card clipped.

## Changes

- **What**: `showPricingTable` opens the unified dialog
(`SubscriptionRequiredDialogContentUnified`) with PrimeVue-path props
for sizing (`style: 'max-width: 95vw'` + `pt`). Since #12593 (FE-578
Phase 6a) made **Reka the default dialog renderer**, those props are
ignored — Reka sizes via `size`/`contentClass`, so the dialog fell back
to `size: 'md'` (`max-w-xl` = 576px). The content root's
`xl:w-[min(1280px,95vw)]` then overflowed the 576px box and shifted
off-center. Moved the width onto a Reka `contentClass` (`w-fit
max-w-[min(1280px,95vw)]`), matching the sibling subscription dialogs in
the same file.

## Review Focus

- **Regression origin**: the broken config landed when #12666 (FE-934,
UnifiedPricingTable) merged on top of #12593's reka-default flip while
still using the PrimeVue config. No merge conflict — the `style` line is
valid but dead, so it broke silently. FE-991 (#12792) predates #12593,
so it still rendered via PrimeVue and looked correct (matching the
report that it was fine there).
- **`w-fit` vs fixed width**: `w-fit` preserves the original "dialog
hugs its content per step" intent — the content root only sets the
1280px width on the pricing step, so confirm/success steps still shrink
instead of floating in a 1280px box.
- Out of scope: the legacy-team / flag-off paths share a PrimeVue
`style` shell and are likely affected the same way under Reka; left for
a follow-up (flag-off is the lower-priority OSS path).

## Verification

- Unit test `useSubscriptionDialog.test.ts` — red without the fix
(dialog has no `contentClass`), green with it.
- Verified live (cloud dev, viewport 1301px): box centered at 1236px
(95vw), no overflow, all three personal cards visible.

## Screenshots

Personal tab, viewport 1301px:

| Before | After |
| --- | --- |
| <img width="480" alt="before"
src="https://github.com/user-attachments/assets/e233fe00-f754-4e34-837f-cf6630ccbfb9"
/> | <img width="480" alt="after"
src="https://github.com/user-attachments/assets/dedd92b7-8707-4865-b7f3-289919043b48"
/> |
2026-06-24 22:23:00 +00:00
CodeJuggernaut
2adef5d9f6 Create script for pointing at prod and staging backends (#13096)
## Summary

Allows engineers to run their localhost frontend while choosing which
backend to point. This PR adds staging and prod as targets.
## Changes

- **What**: New NPM scripts: `dev:cloud:test`, `dev:cloud:staging`, and
`dev:cloud:prod`. `dev:cloud` points at `dev:cloud:test`
- **Breaking**: None

## Why

Currently, the testcloud environment is broken (backend config issue)
and doesn't allow going through the subscription registration process.
This also allows testing frontend code against backend changes being
staged for release, as well as against actual backend production code.
2026-06-24 21:39:42 +00:00
43 changed files with 946 additions and 1455 deletions

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -8,27 +7,36 @@ test.describe('Pricing page @smoke', () => {
await page.goto('/cloud/pricing')
})
const pricingSection = (page: Page) =>
page.locator('section').filter({
has: page.getByRole('heading', { name: /Choose a plan/i })
})
test('shows the three paid tiers and Enterprise', async ({ page }) => {
const section = pricingSection(page)
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
for (const label of ['STANDARD', 'CREATOR', 'PRO', 'ENTERPRISE']) {
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
await expect(
section.locator('span', { hasText: new RegExp(`^${label}$`) })
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
).toBeVisible()
}
await expect(
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
).toBeVisible()
})
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
page
}) => {
const section = pricingSection(page)
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
await expect(section.locator('span', { hasText: /^FREE$/ })).toHaveCount(0)
await expect(
pricingGrid.locator('span', { hasText: /^FREE$/ })
).toHaveCount(0)
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
0
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -1,15 +1,28 @@
<script setup lang="ts">
import Accordion from '../ui/accordion/Accordion.vue'
import AccordionContent from '../ui/accordion/AccordionContent.vue'
import AccordionItem from '../ui/accordion/AccordionItem.vue'
import AccordionTrigger from '../ui/accordion/AccordionTrigger.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
defineProps<{
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>
@@ -25,23 +38,57 @@ defineProps<{
</div>
<!-- Right FAQ list -->
<Accordion type="multiple" class="flex-1">
<AccordionItem
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
:key="faq.id"
:value="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
<AccordionTrigger :class="index === 0 ? 'pt-0' : ''">
{{ faq.question }}
</AccordionTrigger>
<AccordionContent>
<p
class="text-sm whitespace-pre-line text-primary-comfy-canvas/70"
v-html="faq.answer"
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<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-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

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

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingTierCard from './PricingTierCard.vue'
import { SHOW_FREE_TIER } from '../../config/features'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
function subscribeUrl(tier: string): string {
return `${externalLinks.cloud}/cloud/subscribe?tier=${tier}&cycle=monthly`
}
interface PlanFeature {
text: TranslationKey
}
interface PricingPlan {
id: string
labelKey: TranslationKey
summaryKey: TranslationKey
priceKey?: TranslationKey
creditsKey?: TranslationKey
estimateKey?: TranslationKey
ctaKey: TranslationKey
ctaHref: string
featureIntroKey?: TranslationKey
features: PlanFeature[]
andMoreKey?: TranslationKey
image?: string
isPopular?: boolean
isEnterprise?: boolean
}
const freePlan: PricingPlan = {
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
}
const plans: PricingPlan[] = [
...(SHOW_FREE_TIER ? [freePlan] : []),
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
summaryKey: 'pricing.plan.standard.summary',
priceKey: 'pricing.plan.standard.price',
creditsKey: 'pricing.plan.standard.credits',
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: subscribeUrl('standard'),
featureIntroKey: SHOW_FREE_TIER
? 'pricing.plan.standard.featureIntro'
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' },
{ text: 'pricing.plan.standard.feature3' }
]
},
{
id: 'creator',
labelKey: 'pricing.plan.creator.label',
summaryKey: 'pricing.plan.creator.summary',
priceKey: 'pricing.plan.creator.price',
creditsKey: 'pricing.plan.creator.credits',
estimateKey: 'pricing.plan.creator.estimate',
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
isPopular: true
},
{
id: 'pro',
labelKey: 'pricing.plan.pro.label',
summaryKey: 'pricing.plan.pro.summary',
priceKey: 'pricing.plan.pro.price',
creditsKey: 'pricing.plan.pro.credits',
estimateKey: 'pricing.plan.pro.estimate',
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
},
{
id: 'enterprise',
labelKey: 'pricing.enterprise.label',
summaryKey: 'pricing.enterprise.description',
ctaKey: 'pricing.enterprise.cta',
ctaHref: getRoutes(locale).cloudEnterprise,
features: [],
isEnterprise: true
}
]
const standardPlans = plans.filter((p) => !p.isEnterprise)
const enterprisePlan = plans.find((p) => p.isEnterprise)!
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</h1>
<p class="mt-3 text-base text-primary-comfy-canvas">
{{ t('pricing.subtitle', locale) }}
</p>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
<div
:class="
cn(
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
>
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
<!-- Label + badge -->
<div class="flex items-center gap-2 px-6 pt-6">
<span
class="text-primary-comfy-yellow translate-y-0.5 text-base font-bold tracking-wider"
>
{{ t(plan.labelKey, locale) }}
</span>
<span v-if="plan.isPopular" class="flex h-5 items-stretch">
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
</span>
</span>
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
</span>
</div>
<!-- Summary -->
<p class="px-6 text-sm text-primary-comfy-canvas">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price -->
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<div v-else class="px-6 pt-2" />
<!-- Credits -->
<p
v-if="plan.creditsKey"
class="px-6 text-sm text-primary-comfy-canvas"
>
{{ t(plan.creditsKey, locale) }}
</p>
<div v-else class="px-6" />
<!-- Estimate -->
<p
v-if="plan.estimateKey"
class="px-6 text-xs text-primary-comfy-canvas/80"
>
{{ t(plan.estimateKey, locale) }}
</p>
<div v-else class="px-6" />
<!-- Features -->
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<ul class="space-y-2">
<li
v-for="feature in plan.features"
:key="feature.text"
class="flex items-start gap-2"
>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-sm text-primary-comfy-canvas">
{{ t(feature.text, locale) }}
</span>
</li>
</ul>
</div>
<!-- CTA -->
<div class="flex self-end px-6">
<BrandButton
:href="plan.ctaHref"
variant="outline"
size="sm"
class="w-full text-center"
>
{{ t(plan.ctaKey, locale) }}
</BrandButton>
</div>
</PricingTierCard>
</div>
<!-- Mobile: stacked plans -->
<div class="flex flex-col gap-8 lg:hidden">
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
<!-- Main info card -->
<div class="bg-transparency-white-t4 rounded-3xl p-6">
<!-- Label + badge -->
<div class="flex items-center gap-2">
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
>
{{ t(plan.labelKey, locale) }}
</span>
<span v-if="plan.isPopular" class="flex h-5 items-stretch">
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
</span>
</span>
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
</span>
</div>
<!-- Enterprise heading -->
<h2
v-if="plan.isEnterprise"
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<!-- Summary -->
<p class="mt-2 text-sm text-primary-comfy-canvas">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price (standard plans only) -->
<template v-if="plan.priceKey">
<div class="mt-6 flex items-baseline gap-1">
<span
class="font-formula text-5xl font-light text-primary-comfy-canvas"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-sm text-primary-comfy-canvas/55">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<p
v-if="plan.creditsKey"
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
>
{{ t(plan.creditsKey, locale) }}
</p>
<p
v-if="plan.estimateKey"
class="mt-2 text-xs text-primary-comfy-canvas"
>
{{ t(plan.estimateKey, locale) }}
</p>
</template>
<!-- CTA -->
<div class="mt-6">
<BrandButton
:href="plan.ctaHref"
variant="outline"
size="lg"
class="w-full text-center"
>
{{ t(plan.ctaKey, locale) }}
</BrandButton>
</div>
</div>
<!-- Features card -->
<div
v-if="plan.features.length"
class="bg-transparency-white-t4 mt-2 rounded-3xl p-6"
>
<PricingPlanFeatureList
:features="plan.features"
:feature-intro-key="plan.featureIntroKey"
:and-more-key="plan.andMoreKey"
:locale
/>
</div>
<!-- Image (standard plans only) -->
<div v-if="plan.image" class="mt-2">
<img
:src="plan.image"
:alt="t(plan.labelKey, locale)"
class="aspect-21/9 w-full rounded-3xl object-cover"
/>
</div>
</div>
</div>
<!-- Enterprise section (desktop only, mobile handled in plan loop) -->
<div
class="bg-transparency-white-t4 rounded-5xl mt-8 hidden w-full flex-col p-2 lg:mt-8 lg:flex lg:flex-row"
>
<!-- Left side -->
<div
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
>
<div>
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
>
{{ t(enterprisePlan.labelKey, locale) }}
</span>
<h2
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<p class="mt-3 text-sm text-primary-comfy-canvas">
{{ t(enterprisePlan.summaryKey, locale) }}
</p>
</div>
<BrandButton :href="enterprisePlan.ctaHref" variant="outline" size="lg">
{{ t(enterprisePlan.ctaKey, locale) }}
</BrandButton>
</div>
</div>
<!-- Footnote -->
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
{{ t('pricing.footnote', locale) }}
</p>
</section>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('rounded-4.5xl bg-primary-comfy-ink p-8', className)">
<slot />
</div>
</template>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { computed } from 'vue'
import { Component as ComponentIcon } from '@lucide/vue'
import { t } from '../../i18n/translations'
const {
locale = 'en',
estimateKey,
estimateCount
} = defineProps<{
credits: string
label: string
estimateKey?: TranslationKey
estimateCount?: string
locale?: Locale
}>()
const estimate = computed(() => {
if (!estimateKey) return undefined
const text = t(estimateKey, locale)
return estimateCount ? text.replace('{count}', estimateCount) : text
})
</script>
<template>
<div class="mt-6">
<div class="flex items-center gap-2">
<ComponentIcon class="text-primary-comfy-orange size-4 shrink-0" />
<span class="text-primary-warm-white ppformula-text-center text-sm">
<span class="font-extrabold">
{{ credits }}
</span>
{{ label }}
</span>
</div>
<p v-if="estimate" class="text-primary-warm-gray mt-1.5 px-6 text-xs">
{{ estimate }}
</p>
</div>
</template>

View File

@@ -1,34 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { computed } from 'vue'
import { getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import Button from '../ui/button/Button.vue'
import PricingCard from './PricingCard.vue'
import PricingPlanLabel from './PricingPlanLabel.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
const ctaHref = computed(() => getRoutes(locale).contact)
</script>
<template>
<PricingCard class="col-span-full">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3 lg:gap-20">
<div
class="flex flex-col gap-6 lg:col-span-2 lg:flex-row lg:items-center"
>
<PricingPlanLabel :label="t('pricing.enterprise.label', locale)" />
<p class="text-primary-warm-white text-sm">
{{ t('pricing.enterprise.description', locale) }}
</p>
</div>
<Button :href="ctaHref" variant="outline">
{{ t('pricing.enterprise.cta', locale) }}
</Button>
</div>
</PricingCard>
</template>

View File

@@ -1,81 +1,60 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { Check, Clock, X } from '@lucide/vue'
import { cn } from '@comfyorg/tailwind-utils'
import { t } from '../../i18n/translations'
type PlanFeatureType = 'checked' | 'coming'
interface PlanFeature {
text: TranslationKey
type?: PlanFeatureType
included?: boolean
}
export interface PlanFeatureGroup {
titleKey?: TranslationKey
const {
features,
featureIntroKey,
nextUpKey,
andMoreKey,
nextUpClass = 'text-primary-comfy-canvas/80 mt-4 text-sm',
andMoreClass = 'text-primary-comfy-canvas mt-4 text-sm',
listGap = 'space-y-2',
introMargin = 'mb-3',
locale = 'en'
} = defineProps<{
features: PlanFeature[]
}
const { locale = 'en' } = defineProps<{
features: PlanFeatureGroup[]
featureIntroKey?: TranslationKey
nextUpKey?: TranslationKey
andMoreKey?: TranslationKey
nextUpClass?: string
andMoreClass?: string
listGap?: string
introMargin?: string
locale?: Locale
}>()
</script>
<template>
<div class="flex flex-col gap-5">
<div
v-for="(group, groupIndex) in features"
:key="group.titleKey ?? groupIndex"
class="flex flex-col gap-2"
<p
v-if="featureIntroKey"
:class="cn('text-primary-comfy-canvas text-sm font-semibold', introMargin)"
>
{{ t(featureIntroKey, locale) }}
</p>
<ul :class="listGap">
<li
v-for="feature in features"
:key="feature.text"
class="flex items-start gap-2"
>
<p v-if="group.titleKey" class="text-sm text-primary-comfy-canvas">
{{ t(group.titleKey, locale) }}
</p>
<ul class="space-y-2">
<li
v-for="feature in group.features"
:key="feature.text"
class="flex items-start gap-2"
>
<Clock
v-if="feature.type === 'coming'"
class="text-primary-warm-gray mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<Check
v-else-if="feature.included !== false"
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<X
v-else
class="mt-0.5 size-4 shrink-0 text-primary-comfy-canvas/40"
aria-hidden="true"
/>
<span class="sr-only">
{{
feature.type === 'coming'
? t('pricing.plan.feature.status.coming', locale)
: feature.included === false
? t('pricing.plan.feature.status.notIncluded', locale)
: t('pricing.plan.feature.status.included', locale)
}}:
</span>
<span
class="ppformula-text-center text-sm"
:class="
feature.type === 'coming' || feature.included === false
? 'text-primary-warm-gray'
: 'text-primary-warm-white'
"
>
{{ t(feature.text, locale) }}
</span>
</li>
</ul>
</div>
</div>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-primary-comfy-canvas text-sm">
{{ t(feature.text, locale) }}
</span>
</li>
</ul>
<p v-if="nextUpKey" :class="nextUpClass">
{{ t(nextUpKey, locale) }}
</p>
<p v-if="andMoreKey" :class="andMoreClass">
{{ t(andMoreKey, locale) }}
</p>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
label: string
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
:class="
cn(
'text-primary-comfy-yellow text-base font-bold tracking-wider uppercase',
className
)
"
>
{{ label }}
</span>
</template>

View File

@@ -1,67 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { computed } from 'vue'
import { t } from '../../i18n/translations'
const {
locale = 'en',
billingPeriod,
yearlyTotal
} = defineProps<{
price: string
period: string
originalPrice?: string
discount?: string
billingPeriod?: 'monthly' | 'yearly'
yearlyTotal?: string
locale?: Locale
}>()
const billingNote = computed(() => {
if (billingPeriod === 'yearly' && yearlyTotal) {
return t('pricing.period.billedYearly', locale).replace(
'{total}',
yearlyTotal
)
}
if (billingPeriod === 'monthly') {
return t('pricing.period.billedMonthly', locale)
}
return undefined
})
</script>
<template>
<div>
<div class="mt-6 flex items-baseline gap-2">
<span class="font-formula text-5xl font-light text-primary-comfy-canvas">
{{ price }}
</span>
<div class="flex gap-2 max-sm:flex-col">
<div class="flex items-baseline gap-2">
<span
v-if="originalPrice"
class="font-formula text-primary-warm-gray text-sm font-light line-through"
>
{{ originalPrice }}
</span>
<span class="text-primary-warm-white text-sm">
{{ period }}
</span>
</div>
<span
class="text-primary-comfy-yellow text-sm max-sm:text-xs sm:ml-2"
:class="{ 'opacity-0': !discount }"
:aria-hidden="!discount || undefined"
>
{{ discount || ' ' }}
</span>
</div>
</div>
<p v-if="billingNote" class="text-primary-warm-gray mt-2 text-sm">
{{ billingNote }}
</p>
</div>
</template>

View File

@@ -1,143 +0,0 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import { pricingPlans } from '../../data/pricingPlans'
import type { BillingCycle, PricingPlan } from '../../data/pricingPlans'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import Button from '../ui/button/Button.vue'
import ToggleGroup from '../ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '../ui/toggle-group/ToggleGroupItem.vue'
import PricingCard from './PricingCard.vue'
import PricingCredits from './PricingCredits.vue'
import PricingEnterpriseBand from './PricingEnterpriseBand.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingPlanLabel from './PricingPlanLabel.vue'
import PricingPrice from './PricingPrice.vue'
import PricingTeamCard from './PricingTeamCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const billingPeriod = ref<BillingCycle>('yearly')
function displayPriceKey(plan: PricingPlan): TranslationKey | undefined {
if (billingPeriod.value === 'yearly' && plan.yearlyPriceKey) {
return plan.yearlyPriceKey
}
return plan.priceKey
}
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</h1>
<p
class="mx-auto mt-3 max-w-xl text-base text-pretty text-primary-comfy-canvas"
>
{{ t('pricing.subtitle', locale) }}
</p>
</div>
<div class="flex items-center justify-center pb-16">
<ToggleGroup v-model="billingPeriod" type="single">
<ToggleGroupItem value="monthly" class="min-w-48">
{{ t('pricing.period.monthly', locale) }}
</ToggleGroupItem>
<ToggleGroupItem value="yearly" class="min-w-48">
{{ t('pricing.period.yearly', locale) }}
</ToggleGroupItem>
</ToggleGroup>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
<div
:class="
cn(
'rounded-5xl bg-transparency-white-t4 grid gap-2 p-2 max-lg:mx-auto max-lg:max-w-lg',
pricingPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
>
<PricingCard
v-for="plan in pricingPlans"
:key="plan.id"
class="row-span-7 grid grid-rows-subgrid"
>
<!-- Label + badge -->
<div class="flex items-center gap-4">
<PricingPlanLabel
:label="t(plan.labelKey, locale)"
class="ppformula-text-center text-base uppercase"
/>
<Badge v-if="plan.isPopular" variant="callout">
{{ t('pricing.badge.popular', locale) }}</Badge
>
</div>
<!-- Price -->
<PricingPrice
v-if="displayPriceKey(plan)"
:price="t(displayPriceKey(plan)!, locale)"
:period="t('pricing.plan.period', locale)"
:original-price="
billingPeriod === 'yearly' && plan.yearlyPriceKey && plan.priceKey
? t(plan.priceKey, locale)
: undefined
"
:billing-period="billingPeriod"
:yearly-total="
plan.yearlyTotalKey ? t(plan.yearlyTotalKey, locale) : undefined
"
:locale
/>
<!-- Features -->
<div v-if="plan.features.length" class="mt-8">
<PricingPlanFeatureList
:features="[{ features: plan.features }]"
:locale
/>
</div>
<!-- Credits -->
<PricingCredits
v-if="plan.creditsKey"
:credits="t(plan.creditsKey, locale)"
:label="t('pricing.creditsLabel', locale)"
:estimate-key="plan.estimateKey"
:locale
/>
<!-- CTA -->
<div class="mt-8 flex self-end">
<Button
:href="plan.ctaHref(billingPeriod)"
as="a"
variant="outline"
class="w-full text-center"
>
{{ t(plan.ctaKey, locale) }}
</Button>
</div>
</PricingCard>
<PricingTeamCard :billing-period="billingPeriod" :locale />
<PricingEnterpriseBand :locale />
</div>
<!-- Footnote -->
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
{{ t('pricing.footnote', locale) }}
</p>
</section>
</template>

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type { PlanFeatureGroup } from './PricingPlanFeatureList.vue'
import { computed, ref } from 'vue'
import { Component as ComponentIcon } from '@lucide/vue'
import { subscribeUrl } from '../../data/pricingPlans'
import {
formatTeamCreditsLong,
formatTeamCreditsShort,
teamCreditTiers
} from '../../data/teamCreditTiers'
import { t } from '../../i18n/translations'
import Button from '../ui/button/Button.vue'
import Slider from '../ui/slider/Slider.vue'
import PricingCard from './PricingCard.vue'
import PricingCredits from './PricingCredits.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingPlanLabel from './PricingPlanLabel.vue'
import PricingPrice from './PricingPrice.vue'
const { locale = 'en', billingPeriod } = defineProps<{
billingPeriod: 'monthly' | 'yearly'
locale?: Locale
}>()
const teamCreditTierIndex = ref<number[]>([2])
const selectedTeamTier = computed(
() => teamCreditTiers[teamCreditTierIndex.value[0] ?? 0]
)
const selectedTeamPrice = computed(() =>
billingPeriod === 'yearly'
? selectedTeamTier.value.yearlyPrice
: selectedTeamTier.value.monthlyPrice
)
function fmtPrice(n: number): string {
return `$${n.toLocaleString('en-US')}`
}
const teamSaving = computed<string | undefined>(() => {
const base = selectedTeamTier.value.basePrice
const discounted = selectedTeamPrice.value
if (base === discounted) return undefined
const pct = ((base - discounted) / base) * 100
return t('pricing.savePercent', locale)
.replace('{pct}', String(pct))
.replace('{amount}', fmtPrice(base - discounted))
})
const featureGroups: PlanFeatureGroup[] = [
{
titleKey: 'pricing.plan.team.everythingInProPlus',
features: [
{ text: 'pricing.feature.inviteMembers' },
{ text: 'pricing.feature.concurrentWorkflows' },
{ text: 'pricing.feature.sharedCreditPool' },
{ text: 'pricing.feature.roleBasedPermissions' }
]
},
{
titleKey: 'pricing.plan.team.comingSoon',
features: [
{ text: 'pricing.plan.team.sharedWorkflowsAndAssets', type: 'coming' },
{ text: 'pricing.plan.team.projects', type: 'coming' }
]
}
]
const ctaHref = computed(() =>
subscribeUrl(
'team',
billingPeriod,
`team_${selectedTeamTier.value.basePrice}`
)
)
</script>
<template>
<PricingCard class="col-span-full">
<div class="grid grid-cols-1 gap-10 lg:grid-cols-3 lg:gap-20">
<div class="lg:col-span-2 lg:max-w-xl">
<div
class="ppformula-text-center flex flex-col items-start gap-2 lg:flex-row lg:items-center lg:gap-4"
>
<PricingPlanLabel :label="t('pricing.plan.team.label', locale)" />
<p class="text-primary-warm-gray text-sm">
{{ t('pricing.team.description', locale) }}
</p>
</div>
<PricingPrice
:price="fmtPrice(selectedTeamPrice)"
:period="t('pricing.plan.period', locale)"
:original-price="
selectedTeamTier.basePrice !== selectedTeamPrice
? fmtPrice(selectedTeamTier.basePrice)
: undefined
"
:discount="teamSaving"
:billing-period="billingPeriod"
:yearly-total="fmtPrice(selectedTeamPrice * 12)"
:locale
/>
<div class="mt-6">
<Slider
v-model="teamCreditTierIndex"
class="w-full"
:min="0"
:max="teamCreditTiers.length - 1"
:step="1"
:ticks="teamCreditTiers.length"
/>
<div class="relative mt-3 h-6">
<div
v-for="(tier, i) in teamCreditTiers"
:key="tier.credits"
:class="[
'absolute top-0 inline-flex items-center gap-1.5',
'max-lg:-translate-x-[calc(50%-8px)]'
]"
:style="{
left: `calc(${i / (teamCreditTiers.length - 1)} * (100% - 16px))`
}"
>
<ComponentIcon
class="hidden size-4 shrink-0 lg:block"
:class="
i === teamCreditTierIndex[0]
? 'text-primary-comfy-orange'
: 'text-primary-warm-gray'
"
/>
<span
class="text-sm max-sm:text-[10px]"
:class="
i === teamCreditTierIndex[0]
? 'text-primary-warm-white'
: 'text-primary-warm-gray'
"
>
{{ formatTeamCreditsShort(tier.credits) }}
</span>
</div>
</div>
</div>
<PricingCredits
:credits="formatTeamCreditsLong(selectedTeamTier.credits)"
:label="t('pricing.creditsLabel', locale)"
estimate-key="pricing.team.videosEstimate"
:estimate-count="selectedTeamTier.videos.toLocaleString('en-US')"
:locale
/>
</div>
<div>
<PricingPlanFeatureList :features="featureGroups" :locale />
<div class="mt-8">
<Button :href="ctaHref" class="w-full" variant="outline">
{{ t('pricing.plan.team.cta', locale) }}
</Button>
</div>
</div>
</div>
</PricingCard>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<div
class="bg-primary-comfy-ink rounded-4.5xl row-span-7 grid grid-rows-subgrid pb-3"
>
<slot />
</div>
</template>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { Clock } from '@lucide/vue'
import { t } from '../../i18n/translations'
import CheckIcon from '../icons/CheckIcon.vue'
@@ -56,7 +54,11 @@ const features: IncludedFeature[] = [
},
{
titleKey: 'pricing.included.feature11.title',
descriptionKey: 'pricing.included.feature11.description',
descriptionKey: 'pricing.included.feature11.description'
},
{
titleKey: 'pricing.included.feature12.title',
descriptionKey: 'pricing.included.feature12.description',
isComingSoon: true
}
]
@@ -90,9 +92,11 @@ const features: IncludedFeature[] = [
>
<!-- Title -->
<div class="flex items-start gap-3">
<Clock
<img
v-if="feature.isComingSoon"
class="mt-0.5 size-4 shrink-0 text-primary-comfy-canvas/55"
src="/icons/clock.svg"
alt=""
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<CheckIcon
@@ -101,12 +105,6 @@ const features: IncludedFeature[] = [
/>
<p class="text-sm font-medium text-primary-comfy-canvas">
{{ t(feature.titleKey, locale) }}
<span
v-if="feature.isComingSoon"
class="block text-primary-comfy-canvas/55"
>
{{ t('pricing.included.comingSoon', locale) }}
</span>
</p>
</div>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties -- props forwarded via useForwardPropsEmits */
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
<slot v-bind="slotProps" />
</AccordionRoot>
</template>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties -- props forwarded via v-bind */
import type { AccordionContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { reactiveOmit } from '@vueuse/core'
import { AccordionContent } from 'reka-ui'
const props = defineProps<
AccordionContentProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pt-0 pb-6', props.class)">
<slot />
</div>
</AccordionContent>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties -- props forwarded via useForwardProps */
import type { AccordionItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { reactiveOmit } from '@vueuse/core'
import { AccordionItem, useForwardProps } from 'reka-ui'
const props = defineProps<
AccordionItemProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="
cn('border-b border-primary-comfy-canvas/20 last:border-b-0', props.class)
"
>
<slot v-bind="slotProps" />
</AccordionItem>
</template>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties -- props forwarded via v-bind */
import type { AccordionTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Minus } from '@lucide/vue'
import { cn } from '@comfyorg/tailwind-utils'
import { reactiveOmit } from '@vueuse/core'
import { AccordionHeader, AccordionTrigger } from 'reka-ui'
const props = defineProps<
AccordionTriggerProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:text-primary-comfy-yellow focus-visible:border-primary-comfy-yellow/50 focus-visible:ring-primary-comfy-yellow/50 flex flex-1 cursor-pointer items-center justify-between gap-4 py-6 text-left text-lg font-light text-primary-comfy-canvas transition-all outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 md:text-xl',
props.class
)
"
>
<slot />
<slot name="icon">
<span
aria-hidden="true"
class="in-data-[state=open]:text-primary-comfy-yellow relative ml-4 size-6 shrink-0 text-primary-comfy-canvas"
>
<Minus class="pointer-events-none absolute inset-0 size-6" />
<Minus
class="pointer-events-none absolute inset-0 size-6 rotate-90 transition-transform duration-300 ease-out in-data-[state=open]:rotate-0"
/>
</span>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>

View File

@@ -8,9 +8,7 @@ export const badgeVariants = cva({
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm',
callout:
'before:bg-primary-comfy-plum text-primary-warm-white relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-tight uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
},
size: {
md: 'px-4 py-1 text-xs',

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { AnchorHTMLAttributes, Component, HTMLAttributes } from 'vue'
import type { Component, HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
@@ -13,19 +13,17 @@ interface Props extends PrimitiveProps {
disabled?: boolean
prependIcon?: Component
appendIcon?: Component
href?: AnchorHTMLAttributes['href']
}
const {
as,
as = 'button',
asChild,
variant,
size,
class: className,
disabled,
prependIcon,
appendIcon,
href
appendIcon
} = defineProps<Props>()
</script>
@@ -34,10 +32,9 @@ const {
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as ?? (href != null && !disabled ? 'a' : 'button')"
:as
:as-child
:disabled
:href="disabled ? undefined : href"
:class="cn(buttonVariants({ variant, size }), className)"
>
<slot name="prepend">

View File

@@ -1,92 +0,0 @@
<script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import {
SliderRange,
SliderRoot,
SliderThumb,
SliderTrack,
useForwardPropsEmits
} from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const {
class: className,
ticks,
min = 0,
max = 100,
...restProps
} = defineProps<
SliderRootProps & { class?: HTMLAttributes['class']; ticks?: number }
>()
const emits = defineEmits<SliderRootEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps, min, max })),
emits
)
function tickValue(i: number): number {
return min + ((i - 1) / (ticks! - 1)) * (max - min)
}
function isTickFilled(
i: number,
modelValue: number[] | null | undefined
): boolean {
if (!modelValue?.length) return false
const value = tickValue(i)
if (modelValue.length === 1) return value <= modelValue[0]
const sorted = [...modelValue].sort((a, b) => a - b)
return value >= sorted[0] && value <= sorted[sorted.length - 1]
}
</script>
<template>
<SliderRoot
v-slot="{ modelValue }"
data-slot="slider"
:class="
cn(
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)
"
v-bind="forwarded"
>
<template v-if="ticks && ticks > 1">
<span
v-for="i in ticks"
:key="i"
data-slot="slider-tick"
class="pointer-events-none absolute top-1/2 size-2 -translate-1/2 rounded-full"
:class="
isTickFilled(i, modelValue)
? 'bg-primary-warm-white'
: 'bg-primary-warm-gray'
"
:style="{
left: `calc(8px + ${(i - 1) / (ticks - 1)} * (100% - 16px))`
}"
/>
</template>
<SliderTrack
data-slot="slider-track"
class="bg-primary-warm-gray relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
>
<SliderRange
data-slot="slider-range"
class="bg-primary-warm-white absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
/>
</SliderTrack>
<SliderThumb
v-for="(_, key) in modelValue"
:key="key"
data-slot="slider-thumb"
class="bg-primary-warm-white border-primary-comfy-yellow ring-primary-comfy-yellow/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
</SliderRoot>
</template>

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed, provide } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const {
class: className,
variant,
size,
spacing = 0,
...restProps
} = defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
spacing?: number
}
>()
const emits = defineEmits<ToggleGroupRootEmits>()
provide('toggleGroup', {
variant,
size,
spacing
})
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps })),
emits
)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="spacing"
:style="{
'--gap': spacing
}"
v-bind="forwarded"
:class="
cn(
'group/toggle-group ring-primary-warm-white/20 flex w-fit items-center gap-[--spacing(var(--gap))] rounded-2xl p-1.5 ring-2 data-[spacing=default]:data-[variant=outline]:shadow-xs',
className
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupItemProps } from 'reka-ui'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed, inject } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
spacing?: number
}
const {
class: className,
variant,
size,
...restProps
} = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
}
>()
const context = inject<ToggleGroupVariants>('toggleGroup')
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-slot="toggle-group-item"
:data-variant="context?.variant || variant"
:data-size="context?.size || size"
:data-spacing="context?.spacing"
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-xl data-[spacing=0]:last:rounded-r-xl data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
className
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>

View File

@@ -1,20 +0,0 @@
import { cva } from 'class-variance-authority'
export const toggleVariants = cva(
"data-[state=on]:bg-primary-comfy-yellow focus-visible:border-primary-comfy-orange focus-visible:ring-primary-comfy-yellow aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:text-primary-warm-white inline-flex items-center justify-center gap-2 rounded-xl text-xs font-bold whitespace-nowrap uppercase transition-[color,box-shadow] duration-300 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:text-primary-comfy-ink [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
'bg-transparency-white-t4 text-primary-warm-gray hover:cursor-pointer'
},
size: {
default: 'h-9 min-w-20 px-4'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)

View File

@@ -1,167 +0,0 @@
import type { LocalizedText } from '../i18n/translations'
interface PricingFaq {
id: string
question: LocalizedText
answer: LocalizedText
}
export const pricingFaqs: readonly PricingFaq[] = [
{
id: 'how-does-pricing-work',
question: {
en: 'How does Comfy Cloud pricing actually work?',
'zh-CN': 'Comfy Cloud 的定价究竟是如何运作的?'
},
answer: {
en: "Every plan includes a monthly pool of <strong>credits</strong>. Credits are spent on two things: <strong>active GPU time</strong> while a workflow is running, and <strong>Partner Nodes</strong> (proprietary models like Nano Banana Pro). You're never charged for idle time. Building or editing a workflow costs nothing. You only spend while a job is actually running.",
'zh-CN':
'每个计划都包含每月的<strong>积分</strong>池。积分用于两类消耗:工作流运行时的<strong>活跃 GPU 时间</strong>,以及<strong>合作伙伴节点</strong>(如 Nano Banana Pro 等专有模型)。空闲时间不会计费。构建或编辑工作流完全免费。只有任务真正运行时才会扣费。'
}
},
{
id: 'what-is-a-credit-worth',
question: {
en: "What's a credit worth? How far does it go?",
'zh-CN': '一个积分价值多少?能用多久?'
},
answer: {
en: 'Credits map to GPU runtime, so mileage depends on the workflow. As a reference point, a five-second video* uses roughly <strong>11 credits</strong>, so Standard covers a few hundred per month, Creator about double that, and Pro enough for over a thousand.\n\n*Based on 5s videos using the Wan 2.2 Image-to-Video template at default settings (81 frames, 18fps, 640×640, 4-step sampler). Heavier models, higher resolutions, and the inclusion of Partner nodes use more.',
'zh-CN':
'积分对应 GPU 运行时长,因此具体能用多少取决于工作流本身。作为参考:一段 5 秒视频*大约消耗 <strong>11 积分</strong>,因此 Standard 每月可支持数百段Creator 约为其两倍Pro 则足以生成一千多段。\n\n*基于使用 Wan 2.2 图生视频模板在默认设置81 帧、18fps、640×640、4-step sampler下生成 5 秒视频的估算。更复杂的模型、更高分辨率以及加入合作伙伴节点会消耗更多积分。'
}
},
{
id: 'run-out-of-credits',
question: {
en: 'What happens when I run out of credits?',
'zh-CN': '积分用完了会怎样?'
},
answer: {
en: 'You can buy <strong>top-up credits</strong> at any time without changing plans. Monthly credits are spent first; top-ups are only drawn down once your monthly allowance is used up. Top-up credits stay valid for <strong>1 year</strong> from purchase.',
'zh-CN':
'您可以随时购买<strong>充值积分</strong>,无需更换计划。月度积分会优先消耗;只有当月度额度用完后,才会开始使用充值积分。充值积分自购买之日起 <strong>1 年</strong>内有效。'
}
},
{
id: 'do-credits-roll-over',
question: {
en: 'Do unused credits roll over?',
'zh-CN': '未使用的积分会顺延吗?'
},
answer: {
en: "Monthly plan credits reset each billing cycle and don't roll over. <strong>Top-up credits do persist.</strong> They're valid for a year and aren't affected by your monthly reset. Credits work on Comfy Cloud, and on Comfy Desktop <strong>only when calling Partner Nodes.</strong> Comfy Desktop itself is free.",
'zh-CN':
'月度计划积分在每个计费周期重置,不会顺延。<strong>但充值积分会保留。</strong>有效期为一年,且不受每月重置的影响。积分可在 Comfy Cloud 上使用,在 Comfy 桌面版上<strong>仅在调用合作伙伴节点时使用</strong>。Comfy 桌面版本身免费。'
}
},
{
id: 'difference-between-plans',
question: {
en: "What's the difference between Standard, Creator, and Pro?",
'zh-CN': 'Standard、Creator 和 Pro 有什么区别?'
},
answer: {
en: '<strong>Standard -</strong> 30-min max runtime per workflow, 1 concurrent workflow via API. For individuals building workflows.\n\n<strong>Creator -</strong> Everything in Standard plus the ability to <strong>import your own models</strong> (from CivitAI or Hugging Face) and run up to 3 workflows concurrently via API.\n\n<strong>Pro -</strong> Everything in Creator plus <strong>longer runtime (up to 1 hour)</strong> per workflow and up to 5 concurrent workflows via API. For teams running Comfy in production.',
'zh-CN':
'<strong>Standard -</strong> 单个工作流最长运行 30 分钟API 支持 1 个并发工作流。适合构建工作流的个人。\n\n<strong>Creator -</strong> 包含 Standard 的全部功能,并新增<strong>导入自有模型</strong>(来自 CivitAI 或 Hugging Face的能力API 支持最多 3 个并发工作流。\n\n<strong>Pro -</strong> 包含 Creator 的全部功能,并提供<strong>更长的运行时长(最长 1 小时)</strong>API 支持最多 5 个并发工作流。适合在生产环境中运行 Comfy 的团队。'
}
},
{
id: 'how-does-team-plan-work',
question: {
en: 'How does the Team Plan work?',
'zh-CN': '团队计划是如何运作的?'
},
answer: {
en: 'The Team Plan puts your whole team on <strong>one shared credit pool.</strong> Every member draws from the same balance, so you\'re not juggling separate subscriptions. Key things to know:\n\n<strong>One pool, shared.</strong> Everyone generates against the same credit balance.\n<strong>Invite by email.</strong> Add teammates, and resend or revoke access any time.\n<strong>Owners manage billing.</strong> Assign owners who handle payment and buy top-ups for the team.\n<strong>Upgrade in place.</strong> Move an existing workspace to a team and your workflows, models, and assets stay attached.\n\nChoose your monthly credit commitment that fits your team. <a href="https://cloud.comfy.org/?pricing=team" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Get started today.</a>',
'zh-CN':
'团队计划让整个团队共享<strong>一个积分池</strong>。每位成员都从同一余额中扣费,无需分别管理多个订阅。要点:\n\n<strong>一池共享。</strong>所有人都从同一积分余额中生成内容。\n<strong>邮箱邀请。</strong>添加团队成员,随时重新发送或撤销访问权限。\n<strong>所有者管理账单。</strong>指定所有者负责付款,并为团队购买充值积分。\n<strong>原地升级。</strong>将现有工作区升级为团队工作区,您的工作流、模型和资产都将保留。\n\n选择适合您团队的每月积分承诺。<a href="https://cloud.comfy.org/?pricing=team" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">立即开始。</a>'
}
},
{
id: 'team-spending-controls',
question: {
en: 'Can I control how my team spends credits?',
'zh-CN': '我可以控制团队消耗积分的方式吗?'
},
answer: {
en: "Today, owners control the shared pool and top-ups. We're actively building finer-grained controls: <strong>spending limits</strong> at the user, project, and workspace level, <strong>per-project budgets and chargebacks</strong>, <strong>auto-recharge</strong> when the pool runs low, and <strong>self-serve teams beyond 25 seats</strong>.",
'zh-CN':
'目前,所有者掌控共享积分池和充值。我们正在积极开发更细粒度的控制功能:用户、项目和工作区级别的<strong>消费上限</strong>、<strong>按项目预算与分摊</strong>、积分池余额不足时的<strong>自动充值</strong>,以及<strong>超过 25 个席位的自助式团队</strong>。'
}
},
{
id: 'team-per-seat-pricing',
question: {
en: 'Is Team pricing per-seat? Can I add a freelancer just for a project?',
'zh-CN':
'团队计划是按席位计费吗?我可以为某个项目临时加入一位自由职业者吗?'
},
answer: {
en: 'No. Team pricing is based on <strong>your monthly credit commit</strong>, not per-seat. Invite a freelancer, they draw from the shared credit pool while they\'re working, then remove them when the project wraps. <strong>No charge for adding or removing people.</strong> Member count is capped at <strong>25</strong> today; if you hit the cap, <a href="https://portal.usepylon.com/comfy-org/forms/question" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">contact support</a> for additional seats.',
'zh-CN':
'不是。团队定价基于<strong>您每月承诺的积分量</strong>,而非按席位计费。邀请自由职业者后,他们在工作期间从共享积分池中扣费,项目结束后再将其移除即可。<strong>添加或移除成员都不收取额外费用。</strong>目前成员数量上限为 <strong>25</strong> 人;如果您达到上限,请<a href="https://portal.usepylon.com/comfy-org/forms/question" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">联系支持</a>以增加席位。'
}
},
{
id: 'team-upgrade-carryover',
question: {
en: 'What carries over when I upgrade my workspace to a Team plan?',
'zh-CN': '将工作区升级为团队计划时,哪些内容会保留?'
},
answer: {
en: "Everything stays. You're upgrading the workspace itself, so <strong>workflows, models, run history, and top-up credits all remain attached</strong>. The only exception: unused monthly credits from your old plan expire at the end of your current billing cycle, since you're moving to a new credit allowance. <strong>Top-up credits carry over.</strong>",
'zh-CN':
'全部保留。您升级的是工作区本身,因此<strong>工作流、模型、运行历史和充值积分都会保留</strong>。唯一例外:原计划中未使用的月度积分会在当前计费周期结束时失效,因为您将获得新的月度积分额度。<strong>充值积分会顺延。</strong>'
}
},
{
id: 'team-tier-pricing',
question: {
en: 'What do the Team plan tiers cost, and how does the discount work?',
'zh-CN': '团队计划各档次的价格是多少?折扣是怎么算的?'
},
answer: {
en: 'Team plans come in <strong>five tiers from $200 to $2,500/month</strong>, set by a credit-commit slider. A bigger monthly commit means a bigger discount: <strong>annual plans go up to 20% off, monthly plans up to 10%</strong>. The discount starts at the $400 tier (5% annual / 2.5% monthly) and scales from there.',
'zh-CN':
'团队计划共有<strong>五个档次,从每月 $200 到 $2,500</strong>,通过积分承诺滑块进行调整。月度承诺越高,折扣越大:<strong>年付计划最高 20% 折扣,月付计划最高 10% 折扣</strong>。折扣自 $400 档位起(年付 5% / 月付 2.5%),并由此递增。'
}
},
{
id: 'team-collaboration-features',
question: {
en: 'What collaboration features are included at launch?',
'zh-CN': '首发时包含哪些协作功能?'
},
answer: {
en: 'At launch, a Team plan gives you <strong>shared infrastructure</strong>: one credit pool, one bill, one set of admins. <strong>Workflow and asset sharing inside the workspace is coming soon.</strong> In the meantime, to hand off, share the workflow, export the workflow JSON, or drop a Comfy-generated asset into another canvas.',
'zh-CN':
'在首发阶段,团队计划为您提供<strong>共享基础设施</strong>:一个积分池、一份账单、一组管理员。<strong>工作区内的工作流与资产共享功能即将上线。</strong>在此之前,您可以通过共享工作流、导出工作流 JSON 或将 Comfy 生成的资产拖入另一画布来完成交接。'
}
},
{
id: 'team-concurrency',
question: {
en: 'How does concurrency work on a Team plan? Can multiple members run workflows at the same time?',
'zh-CN': '团队计划的并发是如何运作的?多名成员可以同时运行工作流吗?'
},
answer: {
en: 'Yes. <strong>The workspace has 25 concurrent slots (matches the number of members), shared across the team</strong>. A ten-person team and a 25-person team both get the same 25 slots. If a few teammates saturate the pool, the rest queue up until slots free.',
'zh-CN':
'可以。<strong>工作区拥有 25 个并发槽位(与成员上限一致),由整个团队共享</strong>。无论是 10 人团队还是 25 人团队,都享有相同的 25 个槽位。如果少数成员占满了槽位,其他人会排队等待,直到有槽位空出。'
}
},
{
id: 'runtime-and-concurrency-limits',
question: {
en: 'What are the runtime and concurrency limits?',
'zh-CN': '运行时长和并发的限制是什么?'
},
answer: {
en: 'Each workflow has a max runtime of <strong>30 minutes</strong> on Standard and Creator, raised to <strong>1 hour</strong> on Pro. Jobs over the limit are cancelled automatically to keep the system fair and stable. You can queue up to <strong>100 workflows</strong> at once, and run <strong>1 / 3 / 5</strong> concurrently via API on Standard / Creator / Pro. On Team plan, the limit is raised to 25, matching the number of members on the team. Need higher API rate limits? Contact <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
'zh-CN':
'Standard 和 Creator 上,单个工作流的最长运行时长为 <strong>30 分钟</strong>Pro 上提升至 <strong>1 小时</strong>。超出限制的任务会被自动取消,以保持系统的公平与稳定。您可以同时排队最多 <strong>100 个工作流</strong>,并在 Standard / Creator / Pro 上通过 API 分别并发运行 <strong>1 / 3 / 5</strong> 个工作流。在团队计划中,此上限提升至 25与团队成员上限一致。需要更高的 API 速率限制?请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
}
}
] as const

View File

@@ -1,108 +0,0 @@
import type { TranslationKey } from '../i18n/translations'
import { SHOW_FREE_TIER } from '../config/features'
import { externalLinks } from '../config/routes'
export type BillingCycle = 'monthly' | 'yearly'
interface PlanFeature {
text: TranslationKey
included?: boolean
}
export interface PricingPlan {
id: string
labelKey: TranslationKey
priceKey?: TranslationKey
yearlyPriceKey?: TranslationKey
yearlyTotalKey?: TranslationKey
creditsKey?: TranslationKey
estimateKey?: TranslationKey
ctaKey: TranslationKey
ctaHref: (cycle: BillingCycle) => string
features: PlanFeature[]
isPopular?: boolean
}
export const subscribeUrl = (
tier: string,
cycle: BillingCycle,
stop?: string
): string => {
const params = new URLSearchParams({ tier, cycle })
if (stop) params.set('stop', stop)
return `${externalLinks.cloud}/cloud/subscribe?${params.toString()}`
}
const freePlan: PricingPlan = {
id: 'free',
labelKey: 'pricing.plan.free.label',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: () => externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
}
const standardPricingPlans: PricingPlan[] = [
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price',
yearlyPriceKey: 'pricing.plan.standard.yearlyPrice',
yearlyTotalKey: 'pricing.plan.standard.yearlyTotal',
creditsKey: 'pricing.plan.standard.credits',
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: (cycle) => subscribeUrl('standard', cycle),
features: [
{ text: 'pricing.feature.shortRuntime' },
{ text: 'pricing.feature.addCredits' },
{ text: 'pricing.feature.importModels', included: false },
{ text: 'pricing.feature.longRuntime', included: false }
]
},
{
id: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price',
yearlyPriceKey: 'pricing.plan.creator.yearlyPrice',
yearlyTotalKey: 'pricing.plan.creator.yearlyTotal',
creditsKey: 'pricing.plan.creator.credits',
estimateKey: 'pricing.plan.creator.estimate',
ctaKey: 'pricing.plan.creator.cta',
ctaHref: (cycle) => subscribeUrl('creator', cycle),
features: [
{ text: 'pricing.feature.shortRuntime' },
{ text: 'pricing.feature.addCredits' },
{ text: 'pricing.feature.importModels' },
{ text: 'pricing.feature.longRuntime', included: false }
],
isPopular: true
},
{
id: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price',
yearlyPriceKey: 'pricing.plan.pro.yearlyPrice',
yearlyTotalKey: 'pricing.plan.pro.yearlyTotal',
creditsKey: 'pricing.plan.pro.credits',
estimateKey: 'pricing.plan.pro.estimate',
ctaKey: 'pricing.plan.pro.cta',
ctaHref: (cycle) => subscribeUrl('pro', cycle),
features: [
{ text: 'pricing.feature.shortRuntime' },
{ text: 'pricing.feature.addCredits' },
{ text: 'pricing.feature.importModels' },
{ text: 'pricing.feature.longRuntime' }
]
}
]
export const pricingPlans: PricingPlan[] = SHOW_FREE_TIER
? [freePlan, ...standardPricingPlans]
: standardPricingPlans

View File

@@ -1,54 +0,0 @@
export interface TeamCreditTier {
credits: number
basePrice: number
monthlyPrice: number
yearlyPrice: number
videos: number
}
export const teamCreditTiers: readonly TeamCreditTier[] = [
{
credits: 42200,
basePrice: 200,
monthlyPrice: 200,
yearlyPrice: 200,
videos: 3830
},
{
credits: 84400,
basePrice: 400,
monthlyPrice: 390,
yearlyPrice: 380,
videos: 7660
},
{
credits: 147700,
basePrice: 700,
monthlyPrice: 665,
yearlyPrice: 630,
videos: 13405
},
{
credits: 295400,
basePrice: 1400,
monthlyPrice: 1295,
yearlyPrice: 1190,
videos: 26810
},
{
credits: 527500,
basePrice: 2500,
monthlyPrice: 2250,
yearlyPrice: 2000,
videos: 47830
}
] as const
export function formatTeamCreditsLong(n: number): string {
return n.toLocaleString('en-US')
}
export function formatTeamCreditsShort(n: number): string {
const k = n / 1000
return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`
}

View File

@@ -1189,72 +1189,27 @@ const translations = {
'buildWhat.row2a': { en: "DOESN'T EXIST", 'zh-CN': '尚不存在的' },
'buildWhat.row2b': { en: 'YET', 'zh-CN': '事物' },
// PricingSection
'pricing.title': { en: 'Choose a plan', 'zh-CN': '价格' },
// PriceSection
'pricing.title': { en: 'Pricing', 'zh-CN': '价格' },
'pricing.subtitle': {
en: 'Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.',
'zh-CN': '通过简单透明、按使用量计费的方式,访问云端 ComfyUI 工作流。'
},
'pricing.badge.popular': { en: 'MOST POPULAR', 'zh-CN': '最受欢迎' },
'pricing.period.monthly': { en: 'Monthly', 'zh-CN': '按月' },
'pricing.period.yearly': {
en: 'Yearly (Up to 20% off)',
'zh-CN': '按年(最高 20% 优惠)'
},
'pricing.period.billedMonthly': { en: 'Billed monthly', 'zh-CN': '按月计费' },
'pricing.period.billedYearly': {
en: '{total} billed yearly',
'zh-CN': '按年计费 {total}'
},
'pricing.savePercent': {
en: 'Save {pct}% ({amount})',
'zh-CN': '节省 {pct}%{amount}'
},
'pricing.team.videosEstimate': {
en: 'Generates ~{count} 5s videos*',
'zh-CN': '约可生成 {count} 个 5 秒视频*'
},
'pricing.plan.period': { en: '/month', 'zh-CN': '/月' },
'pricing.creditsLabel': { en: 'monthly credits', 'zh-CN': '每月积分' },
'pricing.feature.shortRuntime': {
en: '30 minute max workflow runtime',
'zh-CN': '单个工作流最长运行 30 分钟'
},
'pricing.feature.addCredits': {
en: 'Add more credits anytime',
'zh-CN': '可随时增加积分'
},
'pricing.feature.importModels': {
en: 'Import your own models',
'zh-CN': '导入你自己的模型'
},
'pricing.feature.longRuntime': {
en: 'Longer workflow runtime (up to 1 hr)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.feature.inviteMembers': {
en: 'Invite members',
'zh-CN': '邀请成员'
},
'pricing.feature.concurrentWorkflows': {
en: 'Members can run workflows concurrently',
'zh-CN': '成员可并行运行工作流'
},
'pricing.feature.sharedCreditPool': {
en: 'Shared credit pool for all members',
'zh-CN': '所有成员共享积分池'
},
'pricing.feature.roleBasedPermissions': {
en: 'Role-based permissions',
'zh-CN': '基于角色的权限'
},
'pricing.plan.free.label': { en: 'FREE', 'zh-CN': '免费版' },
'pricing.plan.free.summary': {
en: "Explore Comfy's possibilities",
'zh-CN': '探索 Comfy 的可能性'
},
'pricing.plan.free.price': { en: '$0', 'zh-CN': '$0' },
'pricing.plan.free.credits': { en: '400', 'zh-CN': '400' },
'pricing.plan.free.credits': {
en: 'Includes 400 monthly credits',
'zh-CN': '每月包含 400 积分'
},
'pricing.plan.free.estimate': {
en: 'Generates ~35 5s videos*',
en: '~35 5s videos*',
'zh-CN': '约可生成 35 个 5 秒视频*'
},
'pricing.plan.free.cta': { en: 'START FREE', 'zh-CN': '免费开始' },
@@ -1268,88 +1223,113 @@ const translations = {
},
'pricing.plan.standard.label': { en: 'STANDARD', 'zh-CN': '标准版' },
'pricing.plan.standard.summary': {
en: 'For individuals creating workflows',
'zh-CN': '面向个人工作流创作者'
},
'pricing.plan.standard.price': { en: '$20', 'zh-CN': '$20' },
'pricing.plan.standard.yearlyPrice': { en: '$16', 'zh-CN': '$16' },
'pricing.plan.standard.yearlyTotal': { en: '$192', 'zh-CN': '$192' },
'pricing.plan.standard.credits': { en: '4,200', 'zh-CN': '4,200' },
'pricing.plan.standard.credits': {
en: 'Includes 4,200 monthly credits with top-ups available',
'zh-CN': '每月包含 4,200 积分,并支持充值'
},
'pricing.plan.standard.estimate': {
en: 'Generates ~380 5s videos*',
en: '~380 5s videos*',
'zh-CN': '约可生成 380 个 5 秒视频*'
},
'pricing.plan.standard.cta': {
en: 'SUBSCRIBE TO STANDARD',
'zh-CN': '订阅标准版'
},
'pricing.plan.standard.featureIntro': {
en: 'Everything in Free, plus:',
'zh-CN': '包含免费版全部能力,另加:'
},
'pricing.plan.standard.feature1': {
en: '30-minute max runtime per workflow',
'zh-CN': '单个工作流最长运行 30 分钟'
},
'pricing.plan.standard.feature2': {
en: 'Add more credits anytime',
'zh-CN': '可随时增加积分'
},
'pricing.plan.standard.feature3': {
en: 'Run 1 workflow concurrently (via API)',
'zh-CN': '通过 API 并发运行 1 个工作流'
},
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
'pricing.plan.creator.summary': {
en: 'Small teams building fine-tuned, repeatable workflows',
'zh-CN': '小团队构建精细调优、可复用的工作流'
},
'pricing.plan.creator.price': { en: '$35', 'zh-CN': '$35' },
'pricing.plan.creator.yearlyPrice': { en: '$28', 'zh-CN': '$28' },
'pricing.plan.creator.yearlyTotal': { en: '$336', 'zh-CN': '$336' },
'pricing.plan.creator.credits': { en: '7,400', 'zh-CN': '7,400' },
'pricing.plan.creator.credits': {
en: 'Includes 7,400 monthly credits with top-ups available',
'zh-CN': '每月包含 7,400 积分,并支持充值'
},
'pricing.plan.creator.estimate': {
en: 'Generates ~670 5s videos*',
en: '~670 5s videos*',
'zh-CN': '约可生成 670 个 5 秒视频*'
},
'pricing.plan.creator.cta': {
en: 'SUBSCRIBE TO CREATOR',
'zh-CN': '订阅创作者版'
},
'pricing.plan.creator.featureIntro': {
en: 'Everything in Standard, plus:',
'zh-CN': '包含标准版全部能力,另加:'
},
'pricing.plan.creator.feature1': {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: 'Run up to 3 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 3 个工作流'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
en: 'For growing teams running Comfy in production',
'zh-CN': '面向在生产环境使用 Comfy 的成长型团队'
},
'pricing.plan.pro.price': { en: '$100', 'zh-CN': '$100' },
'pricing.plan.pro.yearlyPrice': { en: '$80', 'zh-CN': '$80' },
'pricing.plan.pro.yearlyTotal': { en: '$960', 'zh-CN': '$960' },
'pricing.plan.pro.credits': { en: '21,100', 'zh-CN': '21,100' },
'pricing.plan.pro.credits': {
en: 'Includes 21,100 monthly credits with top-ups available',
'zh-CN': '每月包含 21,100 积分,并支持充值'
},
'pricing.plan.pro.estimate': {
en: 'Generates ~1,915 5s videos*',
en: '~1,915 5s videos*',
'zh-CN': '约可生成 1,915 个 5 秒视频*'
},
'pricing.plan.pro.cta': { en: 'SUBSCRIBE TO PRO', 'zh-CN': '订阅专业版' },
'pricing.plan.team.label': { en: 'TEAM', 'zh-CN': '团队版' },
'pricing.plan.team.cta': {
en: 'SUBSCRIBE TO TEAM',
'zh-CN': '订阅团队版'
'pricing.plan.pro.featureIntro': {
en: 'Everything in Creator, plus:',
'zh-CN': '包含创作者版全部能力,另加:'
},
'pricing.plan.team.everythingInProPlus': {
en: 'Everything in Pro, plus:',
'zh-CN': '包含专业版的全部功能,另加:'
'pricing.plan.pro.feature1': {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.team.description': {
en: 'Built for teams collaborating on workflows together.',
'zh-CN': '为协作开发工作流的团队打造。'
},
'pricing.plan.team.comingSoon': {
en: 'Coming soon...',
'zh-CN': '即将推出…'
},
'pricing.plan.team.sharedWorkflowsAndAssets': {
en: 'Shared workflows & assets',
'zh-CN': '共享工作流与资产'
},
'pricing.plan.team.projects': {
en: 'Projects',
'zh-CN': '项目'
},
'pricing.plan.feature.status.coming': {
en: 'Coming soon',
'zh-CN': '即将推出'
},
'pricing.plan.feature.status.included': {
en: 'Included',
'zh-CN': '已包含'
},
'pricing.plan.feature.status.notIncluded': {
en: 'Not included',
'zh-CN': '未包含'
'pricing.plan.pro.feature2': {
en: 'Run up to 5 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 5 个工作流'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.description': {
en: 'Need more members? Looking for more flexibility or custom features?',
'zh-CN': '需要更多成员?想要更多灵活性或定制功能'
'pricing.enterprise.heading': {
en: 'Looking for Enterprise Solutions?',
'zh-CN': '在寻找企业级解决方案'
},
'pricing.enterprise.description': {
en: 'For teams running Comfy in production, and at scale.',
'zh-CN': '面向在生产环境和规模化场景中运行 Comfy 的团队。'
},
'pricing.enterprise.cta': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
'pricing.enterprise.featureIntro': {
en: 'Everything in Pro, plus:',
'zh-CN': '包含专业版全部能力,另加:'
},
'pricing.enterprise.cta': { en: 'Contact Us', 'zh-CN': '联系我们' },
'pricing.enterprise.feature1': {
en: 'Annual commitments with bulk pricing and custom compute packages',
'zh-CN': '支持年度承诺、批量定价与定制算力套餐'
@@ -1377,10 +1357,6 @@ const translations = {
en: "What's included\nin the Comfy plan",
'zh-CN': 'Comfy 计划\n包含哪些内容'
},
'pricing.included.comingSoon': {
en: '(coming soon)',
'zh-CN': '(即将推出)'
},
'pricing.included.feature1.title': {
en: 'Machine Setup',
'zh-CN': '机器配置'
@@ -1394,9 +1370,9 @@ const translations = {
'zh-CN': '单个任务时限'
},
'pricing.included.feature2.description': {
en: 'Each workflow run has a maximum duration of 30 minutes. On the Pro plan, the time limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
en: 'On our Standard and Creator plans, each workflow has a maximum run time of 30 minutes. On the Pro plan, the limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
'zh-CN':
'每个工作流运行的最长时长为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
'Standard 和 Creator 计划下,每个工作流最长运行时间为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
},
'pricing.included.feature3.title': {
en: 'Usage',
@@ -1421,9 +1397,9 @@ const translations = {
'zh-CN': '随时加购积分'
},
'pricing.included.feature5.description': {
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
en: 'Purchase additional credits at any time. Top-up credits are valid for 1 year from the date of purchase and do not roll over with your monthly plan.',
'zh-CN':
'可随时购买额外积分。未使用的充值积分将自动顺延至下个月,最长可保留 1 年。'
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
},
'pricing.included.feature6.title': {
en: 'Pre-installed models',
@@ -1440,16 +1416,16 @@ const translations = {
'pricing.included.feature7.description': {
en: "Comfy Cloud currently supports a variety of the most-used custom nodes from the ComfyUI community. We're expanding support regularly based on demand and compatibility.",
'zh-CN':
'Comfy Cloud 目前支持 ComfyUI 社区中最常用的多种自定义节点。我们会根据需求和兼容性持续扩展支持范围。'
'Comfy Cloud 目前支持 ComfyUI 社区中最常用的多种自定义节点,并根据需求和兼容性持续扩展支持范围。'
},
'pricing.included.feature8.title': {
en: 'Partner Nodes',
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across <strong>Comfy Cloud and local ComfyUI</strong>. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 <strong>Comfy Cloud 和本地 ComfyUI</strong> 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',
@@ -1469,19 +1445,23 @@ const translations = {
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA打造专属风格。'
},
'pricing.included.feature11.title': {
en: 'Run Workflows via API',
'zh-CN': '通过 API 运行工作流'
},
'pricing.included.feature11.description': {
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
'zh-CN':
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
},
'pricing.included.feature12.title': {
en: 'Parallel job execution',
'zh-CN': '并行任务执行'
},
'pricing.included.feature11.description': {
'pricing.included.feature12.description': {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
'pricing.faq.heading': {
en: 'Q&A',
'zh-CN': '问答'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },
'player.pause': { en: 'Pause', 'zh-CN': '暂停' },

View File

@@ -1,12 +1,10 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PricingSection from '../../components/pricing/PricingSection.vue'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import FAQSection from '../../components/pricing/FAQSection.vue'
---
<BaseLayout title="Pricing — Comfy Cloud">
<PricingSection client:load />
<PriceSection client:load />
<WhatsIncludedSection />
<FAQSection client:visible />
</BaseLayout>

View File

@@ -1,12 +1,10 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PricingSection from '../../../components/pricing/PricingSection.vue'
import PriceSection from '../../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
import FAQSection from '../../../components/pricing/FAQSection.vue'
---
<BaseLayout title="定价 — Comfy Cloud">
<PricingSection locale="zh-CN" client:load />
<PriceSection locale="zh-CN" client:load />
<WhatsIncludedSection locale="zh-CN" />
<FAQSection locale="zh-CN" client:visible />
</BaseLayout>

View File

@@ -62,7 +62,6 @@
@theme {
--color-site-dropdown: #332b38;
--color-primary-comfy-yellow: #f2ff59;
--color-primary-comfy-orange: #fabc25;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-ink-light: #2a2330;
--color-primary-comfy-canvas: #c2bfb9;

View File

@@ -19,7 +19,10 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",

View File

@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
const executionStore = useExecutionStore()
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
() => {
const workflow = workflowStore.activeWorkflow
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
},
([workflow, status]) => {
if (workflow && status !== undefined && status !== 'running') {
executionStore.clearWorkflowStatus(workflow)
}
},

View File

@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))

View File

@@ -246,3 +246,37 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
expect(mockFetchApi).not.toHaveBeenCalled()
})
})
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
async function loadAudioUIWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.AudioWidget')
if (!extension)
throw new Error('Comfy.AudioWidget extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
}
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
it('excludes the audio player from workflow and prompt serialization', async () => {
const AUDIO_UI = await loadAudioUIWidget()
const domWidget = {
serialize: true,
options: {} as Record<string, unknown>
}
const node = fromAny<LGraphNode, unknown>({
addDOMWidget: vi.fn(() => domWidget),
constructor: { nodeData: { output_node: false } }
})
AUDIO_UI(node, 'audioUI')
expect(domWidget.serialize).toBe(false)
expect(domWidget.options.serialize).toBe(false)
})
})

View File

@@ -128,6 +128,7 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
audioUIWidget.options.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')

View File

@@ -129,6 +129,21 @@ describe('useSubscriptionDialog', () => {
expect(props).not.toHaveProperty('onChooseTeam')
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is silently ignored and collapses the wide table to the
// default md (576px) frame.
expect(dialogComponentProps).toHaveProperty('contentClass')
expect(dialogComponentProps).not.toHaveProperty('style')
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true

View File

@@ -129,18 +129,15 @@ export const useSubscriptionDialog = () => {
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
},
dialogComponentProps: {
// The dialog hugs its content so each step sizes itself: the pricing
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
// compact confirm/success steps shrink instead of floating in the big
// pricing modal. Sizes are set on the content root per checkoutStep.
style: 'max-width: 95vw; max-height: 90vh;',
pt: {
root: { class: 'rounded-2xl bg-transparent' },
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
// `style` width is ignored here and collapses the table to the default
// `md` frame. `w-fit` lets each step hug its content — the pricing
// table fills its 1280px content while the compact confirm/success
// steps shrink (the content root sets its own width per checkoutStep).
renderer: 'reka',
size: 'full',
contentClass:
'w-fit max-w-[min(1280px,95vw)] sm:max-w-[min(1280px,95vw)] max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
})
return

View File

@@ -0,0 +1,227 @@
import { describe, expect, it } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { JobState } from '@/types/queue'
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
import { buildJobDisplay, iconForJobState } from '@/utils/queueDisplay'
type QueueDisplayTask = Parameters<typeof buildJobDisplay>[0]
type PreviewOutput = NonNullable<QueueDisplayTask['previewOutput']>
function createJob(
status: JobListItem['status'],
overrides: Partial<JobListItem> = {}
): JobListItem {
return {
id: 'job-123456',
status,
create_time: 1_710_000_000_000,
priority: 12,
...overrides
}
}
function createTask({
job,
jobId = 'job-123456',
createTime = 1_710_000_000_000,
executionTime,
executionTimeInSeconds,
previewOutput
}: {
job?: Partial<JobListItem>
jobId?: string
createTime?: number
executionTime?: number
executionTimeInSeconds?: number
previewOutput?: PreviewOutput
} = {}): QueueDisplayTask {
return {
job: createJob(job?.status ?? 'pending', job),
jobId,
createTime,
executionTime,
executionTimeInSeconds,
previewOutput
} as QueueDisplayTask
}
function createCtx(
overrides: Partial<BuildJobDisplayCtx> = {}
): BuildJobDisplayCtx {
return {
t: (key, values) => {
const entries = Object.entries(values ?? {})
if (!entries.length) return key
return `${key}(${entries
.map(([name, value]) => `${name}=${String(value)}`)
.join(',')})`
},
locale: 'en-US',
formatClockTimeFn: (ts, locale) => `${locale}:${ts}`,
isActive: false,
...overrides
}
}
describe('iconForJobState', () => {
it.for<[JobState, string]>([
['pending', 'icon-[lucide--loader-circle]'],
['initialization', 'icon-[lucide--server-crash]'],
['running', 'icon-[lucide--zap]'],
['completed', 'icon-[lucide--check-check]'],
['failed', 'icon-[lucide--alert-circle]']
])('maps %s to its icon', ([state, icon]) => {
expect(iconForJobState(state)).toBe(icon)
})
})
describe('buildJobDisplay', () => {
it('shows the added hint for pending jobs when requested', () => {
expect(
buildJobDisplay(
createTask(),
'pending',
createCtx({ showAddedHint: true })
)
).toEqual({
iconName: 'icon-[lucide--check]',
primary: 'queue.jobAddedToQueue',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('shows queued time for pending and initializing jobs', () => {
expect(buildJobDisplay(createTask(), 'pending', createCtx())).toMatchObject(
{
iconName: 'icon-[lucide--loader-circle]',
primary: 'queue.inQueue',
secondary: 'en-US:1710000000000',
showClear: true
}
)
expect(
buildJobDisplay(createTask(), 'initialization', createCtx())
).toMatchObject({
iconName: 'icon-[lucide--server-crash]',
primary: 'queue.initializingAlmostReady',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('formats active running progress from the injected context', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx({
isActive: true,
totalPercent: 42.7,
currentNodePercent: -10,
currentNodeName: 'KSampler'
})
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'sideToolbar.queueProgressOverlay.total(percent=43%)',
secondary:
'KSampler sideToolbar.queueProgressOverlay.colonPercent(percent=0%)',
showClear: true
})
})
it('uses a compact running label when the job is not active', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'g.running',
secondary: '',
showClear: true
})
})
it('shows local completed jobs as the preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTimeInSeconds: 3.51,
previewOutput: {
filename: 'preview.png',
isImage: true,
url: '/api/view?filename=preview.png&type=output&subfolder='
} as PreviewOutput
}),
'completed',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--check-check]',
iconImageUrl: '/api/view?filename=preview.png&type=output&subfolder=',
primary: 'preview.png',
secondary: '3.51s',
showClear: false
})
})
it('shows cloud completed jobs as elapsed time', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTime: 64_000,
executionTimeInSeconds: 64
}),
'completed',
createCtx({ isCloud: true })
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'queue.completedIn(duration=1m 4s)',
secondary: '64.00s',
showClear: false
})
})
it('falls back to job title for completed jobs without a preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed',
priority: 42
}
}),
'completed',
createCtx()
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'g.job #42',
secondary: '',
showClear: false
})
})
it('shows failed jobs as clearable failures', () => {
expect(buildJobDisplay(createTask(), 'failed', createCtx())).toEqual({
iconName: 'icon-[lucide--alert-circle]',
primary: 'g.failed',
secondary: 'g.failed',
showClear: true
})
})
})