Merge branch 'feat/pricing-period-team' into feat(website)/mcp-pricing-pages
# Conflicts: # apps/website/src/components/blocks/FAQSplit01.vue
@@ -15,6 +15,11 @@ reviews:
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
override_requested_reviewers_only: true
|
||||
# Explicitly disable the built-in docstring coverage check, which is
|
||||
# enabled via organization-level settings. This repo opts out at the
|
||||
# repo level without affecting other org repos.
|
||||
docstrings:
|
||||
mode: 'off'
|
||||
custom_checks:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
|
||||
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -85,6 +85,16 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
63
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: CLA Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read # 'read' is enough because signatures live in a REMOTE repo
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PAT required to write to the centralized signatures repo.
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
# Where the CLA document lives (shown to contributors)
|
||||
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
|
||||
|
||||
# Centralized signature storage
|
||||
remote-organization-name: comfy-org
|
||||
remote-repository-name: comfy-cla
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
🎉 Thank you for your contribution, we really appreciate it! 🎉
|
||||
|
||||
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
|
||||
|
||||
- Confirm that you own your contribution.
|
||||
- Keep the right to reuse your own code.
|
||||
- Grant us a copyright license to include and share it within our projects.
|
||||
|
||||
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
|
||||
|
||||
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
|
||||
|
||||
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
|
||||
|
||||
custom-allsigned-prcomment: |
|
||||
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.
|
||||
@@ -78,6 +78,11 @@ const config: StorybookConfig = {
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/billing/useBillingContext',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
@@ -7,36 +8,27 @@ test.describe('Pricing page @smoke', () => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
const pricingSection = (page: Page) =>
|
||||
page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: /Choose a plan/i })
|
||||
})
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const section = pricingSection(page)
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO', 'ENTERPRISE']) {
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
section.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 pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
const section = pricingSection(page)
|
||||
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(section.locator('span', { hasText: /^FREE$/ })).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 75 KiB |
@@ -1,28 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
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'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
const { faqs } = defineProps<{
|
||||
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>
|
||||
@@ -38,57 +25,23 @@ function toggle(index: number) {
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
<Accordion type="multiple" class="flex-1">
|
||||
<AccordionItem
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
:value="faq.id"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -40,12 +40,12 @@ const {
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -66,10 +66,10 @@ const {
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
|
||||
@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
<template>
|
||||
<footer
|
||||
ref="footerRef"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
|
||||
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
|
||||
>
|
||||
<div
|
||||
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"
|
||||
|
||||
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
|
||||
<div class="flex w-full items-end justify-between p-4">
|
||||
<div class="gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
|
||||
<!-- Mobile metadata -->
|
||||
<div v-if="mobile" class="mt-2 gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
|
||||
>
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
|
||||
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
|
||||
19
apps/website/src/components/pricing/FAQSection.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
@@ -1,393 +0,0 @@
|
||||
<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>
|
||||
15
apps/website/src/components/pricing/PricingCard.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
43
apps/website/src/components/pricing/PricingCredits.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
@@ -1,60 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Clock, X } from '@lucide/vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
type PlanFeatureType = 'checked' | 'coming'
|
||||
|
||||
interface PlanFeature {
|
||||
text: TranslationKey
|
||||
type?: PlanFeatureType
|
||||
included?: boolean
|
||||
}
|
||||
|
||||
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<{
|
||||
export interface PlanFeatureGroup {
|
||||
titleKey?: TranslationKey
|
||||
features: PlanFeature[]
|
||||
featureIntroKey?: TranslationKey
|
||||
nextUpKey?: TranslationKey
|
||||
andMoreKey?: TranslationKey
|
||||
nextUpClass?: string
|
||||
andMoreClass?: string
|
||||
listGap?: string
|
||||
introMargin?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
features: PlanFeatureGroup[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
v-for="(group, groupIndex) in features"
|
||||
:key="group.titleKey ?? groupIndex"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
23
apps/website/src/components/pricing/PricingPlanLabel.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
67
apps/website/src/components/pricing/PricingPrice.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<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>
|
||||
143
apps/website/src/components/pricing/PricingSection.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<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>
|
||||
171
apps/website/src/components/pricing/PricingTeamCard.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-primary-comfy-ink rounded-4.5xl row-span-7 grid grid-rows-subgrid pb-3"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<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'
|
||||
|
||||
@@ -54,11 +56,7 @@ const features: IncludedFeature[] = [
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature12.title',
|
||||
descriptionKey: 'pricing.included.feature12.description',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -92,11 +90,9 @@ const features: IncludedFeature[] = [
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="flex items-start gap-3">
|
||||
<img
|
||||
<Clock
|
||||
v-if="feature.isComingSoon"
|
||||
src="/icons/clock.svg"
|
||||
alt=""
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
class="mt-0.5 size-4 shrink-0 text-primary-comfy-canvas/55"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon
|
||||
@@ -105,6 +101,12 @@ 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>
|
||||
|
||||
|
||||
16
apps/website/src/components/ui/accordion/Accordion.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
29
apps/website/src/components/ui/accordion/AccordionItem.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<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>
|
||||
@@ -8,7 +8,9 @@ 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'
|
||||
'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'
|
||||
},
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { Component, HTMLAttributes } from 'vue'
|
||||
import type { AnchorHTMLAttributes, Component, HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -13,17 +13,19 @@ interface Props extends PrimitiveProps {
|
||||
disabled?: boolean
|
||||
prependIcon?: Component
|
||||
appendIcon?: Component
|
||||
href?: AnchorHTMLAttributes['href']
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
as,
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled,
|
||||
prependIcon,
|
||||
appendIcon
|
||||
appendIcon,
|
||||
href
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
@@ -32,9 +34,10 @@ const {
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as="as ?? (href != null && !disabled ? 'a' : 'button')"
|
||||
:as-child
|
||||
:disabled
|
||||
:href="disabled ? undefined : href"
|
||||
:class="cn(buttonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
|
||||
92
apps/website/src/components/ui/slider/Slider.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<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>
|
||||
63
apps/website/src/components/ui/toggle-group/ToggleGroup.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<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>
|
||||
@@ -0,0 +1,56 @@
|
||||
<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>
|
||||
20
apps/website/src/components/ui/toggle/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
)
|
||||
167
apps/website/src/data/pricingFaq.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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 50 seats</strong>.",
|
||||
'zh-CN':
|
||||
'目前,所有者掌控共享积分池和充值。我们正在积极开发更细粒度的控制功能:用户、项目和工作区级别的<strong>消费上限</strong>、<strong>按项目预算与分摊</strong>、积分池余额不足时的<strong>自动充值</strong>,以及<strong>超过 50 个席位的自助式团队</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>50</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>50</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 50 concurrent slots (matches the number of members), shared across the team</strong>. A ten-person team and a 50-person team both get the same 50 slots. If a few teammates saturate the pool, the rest queue up until slots free.',
|
||||
'zh-CN':
|
||||
'可以。<strong>工作区拥有 50 个并发槽位(与成员上限一致),由整个团队共享</strong>。无论是 10 人团队还是 50 人团队,都享有相同的 50 个槽位。如果少数成员占满了槽位,其他人会排队等待,直到有槽位空出。'
|
||||
}
|
||||
},
|
||||
{
|
||||
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 50, 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> 个工作流。在团队计划中,此上限提升至 50,与团队成员上限一致。需要更高的 API 速率限制?请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
108
apps/website/src/data/pricingPlans.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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
|
||||
54
apps/website/src/data/teamCreditTiers.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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`
|
||||
}
|
||||
@@ -1189,27 +1189,72 @@ const translations = {
|
||||
'buildWhat.row2a': { en: "DOESN'T EXIST", 'zh-CN': '尚不存在的' },
|
||||
'buildWhat.row2b': { en: 'YET', 'zh-CN': '事物' },
|
||||
|
||||
// PriceSection
|
||||
'pricing.title': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
// PricingSection
|
||||
'pricing.title': { en: 'Choose a plan', '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: 'Includes 400 monthly credits',
|
||||
'zh-CN': '每月包含 400 积分'
|
||||
},
|
||||
'pricing.plan.free.credits': { en: '400', 'zh-CN': '400' },
|
||||
'pricing.plan.free.estimate': {
|
||||
en: '~35 5s videos*',
|
||||
en: 'Generates ~35 5s videos*',
|
||||
'zh-CN': '约可生成 35 个 5 秒视频*'
|
||||
},
|
||||
'pricing.plan.free.cta': { en: 'START FREE', 'zh-CN': '免费开始' },
|
||||
@@ -1223,113 +1268,88 @@ 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.credits': {
|
||||
en: 'Includes 4,200 monthly credits with top-ups available',
|
||||
'zh-CN': '每月包含 4,200 积分,并支持充值'
|
||||
},
|
||||
'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.estimate': {
|
||||
en: '~380 5s videos*',
|
||||
en: 'Generates ~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.credits': {
|
||||
en: 'Includes 7,400 monthly credits with top-ups available',
|
||||
'zh-CN': '每月包含 7,400 积分,并支持充值'
|
||||
},
|
||||
'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.estimate': {
|
||||
en: '~670 5s videos*',
|
||||
en: 'Generates ~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.credits': {
|
||||
en: 'Includes 21,100 monthly credits with top-ups available',
|
||||
'zh-CN': '每月包含 21,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.estimate': {
|
||||
en: '~1,915 5s videos*',
|
||||
en: 'Generates ~1,915 5s videos*',
|
||||
'zh-CN': '约可生成 1,915 个 5 秒视频*'
|
||||
},
|
||||
'pricing.plan.pro.cta': { en: 'SUBSCRIBE TO PRO', 'zh-CN': '订阅专业版' },
|
||||
'pricing.plan.pro.featureIntro': {
|
||||
en: 'Everything in Creator, plus:',
|
||||
'zh-CN': '包含创作者版全部能力,另加:'
|
||||
|
||||
'pricing.plan.team.label': { en: 'TEAM', 'zh-CN': '团队版' },
|
||||
'pricing.plan.team.cta': {
|
||||
en: 'SUBSCRIBE TO TEAM',
|
||||
'zh-CN': '订阅团队版'
|
||||
},
|
||||
'pricing.plan.pro.feature1': {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
'pricing.plan.team.everythingInProPlus': {
|
||||
en: 'Everything in Pro, plus:',
|
||||
'zh-CN': '包含专业版的全部功能,另加:'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: 'Run up to 5 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 5 个工作流'
|
||||
'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.enterprise.label': { en: 'ENTERPRISE', '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': '包含专业版全部能力,另加:'
|
||||
en: 'Need more members? Looking for more flexibility or custom features?',
|
||||
'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': '支持年度承诺、批量定价与定制算力套餐'
|
||||
@@ -1357,6 +1377,10 @@ 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': '机器配置'
|
||||
@@ -1370,9 +1394,9 @@ const translations = {
|
||||
'zh-CN': '单个任务时限'
|
||||
},
|
||||
'pricing.included.feature2.description': {
|
||||
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.',
|
||||
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.',
|
||||
'zh-CN':
|
||||
'Standard 和 Creator 计划下,每个工作流最长运行时间为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
|
||||
'每个工作流运行的最长时长为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
|
||||
},
|
||||
'pricing.included.feature3.title': {
|
||||
en: 'Usage',
|
||||
@@ -1397,9 +1421,9 @@ const translations = {
|
||||
'zh-CN': '随时加购积分'
|
||||
},
|
||||
'pricing.included.feature5.description': {
|
||||
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.',
|
||||
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
|
||||
'zh-CN':
|
||||
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
|
||||
'可随时购买额外积分。未使用的充值积分将自动顺延至下个月,最长可保留 1 年。'
|
||||
},
|
||||
'pricing.included.feature6.title': {
|
||||
en: 'Pre-installed models',
|
||||
@@ -1416,16 +1440,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 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>.',
|
||||
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>.',
|
||||
'zh-CN':
|
||||
'通过 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>。'
|
||||
'通过 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>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
@@ -1445,23 +1469,19 @@ 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.feature12.description': {
|
||||
'pricing.included.feature11.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': '暂停' },
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import PricingSection from '../../components/pricing/PricingSection.vue'
|
||||
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
|
||||
import FAQSection from '../../components/pricing/FAQSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Pricing — Comfy Cloud">
|
||||
<PriceSection client:load />
|
||||
<PricingSection client:load />
|
||||
<WhatsIncludedSection />
|
||||
<FAQSection client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../../components/pricing/PriceSection.vue'
|
||||
import PricingSection from '../../../components/pricing/PricingSection.vue'
|
||||
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
|
||||
import FAQSection from '../../../components/pricing/FAQSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="定价 — Comfy Cloud">
|
||||
<PriceSection locale="zh-CN" client:load />
|
||||
<PricingSection locale="zh-CN" client:load />
|
||||
<WhatsIncludedSection locale="zh-CN" />
|
||||
<FAQSection locale="zh-CN" client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
@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;
|
||||
|
||||
@@ -56,12 +56,16 @@ class ComfyPropertiesPanel {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
readonly toggleButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
this.toggleButton = page.getByRole('button', {
|
||||
name: 'Toggle properties panel'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -352,20 +352,11 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.selectionFooter = page.getByTestId('assets-selection-bar')
|
||||
this.selectionCountButton = page.getByText(/\d+ selected/)
|
||||
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
|
||||
@@ -112,6 +112,10 @@ export const TestIds = {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
card: 'asset-card'
|
||||
},
|
||||
subgraphEditor: {
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
iconEye: 'icon-eye',
|
||||
|
||||
@@ -223,4 +223,23 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should focus keybindings search when opening manage shortcuts', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Keybindings...')
|
||||
).toBeFocused()
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Settings...')
|
||||
).not.toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
|
||||
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('Use button ghost-places a loader populated with the model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const card = comfyPage.page.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
// Dialog closes and the ghost is armed; the node is not placed until the
|
||||
// user clicks the canvas.
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
|
||||
})
|
||||
})
|
||||
99
browser_tests/tests/cloud-asset-promoted-widget.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
|
||||
|
||||
interface WidgetSnapshot {
|
||||
type: string
|
||||
value: string
|
||||
hasLayout: boolean
|
||||
}
|
||||
|
||||
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
|
||||
return await page.evaluate(
|
||||
({ nodeId, widgetName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
|
||||
|
||||
return {
|
||||
type: widget?.type ?? '',
|
||||
value: String(widget?.value ?? ''),
|
||||
hasLayout: widget?.last_y != null
|
||||
}
|
||||
},
|
||||
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Promoted subgraph asset widgets',
|
||||
{ tag: ['@cloud', '@canvas', '@widget'] },
|
||||
() => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('legacy asset browser selection updates the promoted host widget value', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => getHostWidgetSnapshot(comfyPage.page))
|
||||
.toMatchObject({
|
||||
type: 'asset',
|
||||
hasLayout: true
|
||||
})
|
||||
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
|
||||
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
|
||||
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
|
||||
await hostNode.centerOnNode()
|
||||
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
|
||||
await promotedWidget.click()
|
||||
|
||||
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const assetCard = modal
|
||||
.getByTestId(TestIds.assets.card)
|
||||
.filter({ hasText: SELECTED_MODEL })
|
||||
.first()
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
|
||||
)
|
||||
.toBe(SELECTED_MODEL)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
@@ -14,15 +17,31 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('preserves share auth attribution before redirecting logged-out users', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(new URL('/?share=abc', APP_URL).toString())
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => sessionStorage.getItem(key),
|
||||
SHARE_AUTH_STORAGE_KEY
|
||||
)
|
||||
)
|
||||
.toBe(JSON.stringify({ share: 'abc' }))
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
|
||||
@@ -254,21 +254,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -288,20 +275,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
@@ -34,19 +34,17 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
if (!observedContentType) {
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
}
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
@@ -69,24 +67,11 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
@@ -95,7 +80,6 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -151,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -13,10 +13,6 @@ import type {
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
@@ -180,12 +176,10 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -194,11 +188,9 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
@@ -235,10 +227,8 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -286,11 +276,9 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -298,16 +286,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -342,10 +327,8 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
@@ -355,7 +338,6 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
@@ -391,10 +373,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -405,11 +385,9 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -420,10 +398,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -431,15 +407,10 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -448,14 +419,11 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
@@ -481,10 +449,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
@@ -565,8 +531,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// job-gamma is the first card; its detail carries a valid workflow so
|
||||
// extraction succeeds and the filename prompt opens.
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
@@ -614,8 +578,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Strip the workflow field so extraction yields null and the export
|
||||
// action returns { success: false, error: 'No workflow…' }.
|
||||
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
|
||||
|
||||
@@ -625,7 +587,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
// Filename prompt should be skipped: extraction fails before the prompt.
|
||||
await expect(comfyPage.toast.toastWarnings).toBeVisible()
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
@@ -639,23 +600,18 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
// useKeyModifier('Control') needs keyboard events, not click modifiers.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
// dispatchEvent avoids the selection footer intercepting a right click.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -664,7 +620,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -692,7 +647,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -704,7 +658,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -712,21 +665,67 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
// outputs_count: 2 which would inflate the total.
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection count sums the outputs of a stacked asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection bar stays capped, not stretched, on a wide panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
|
||||
await expect(gutter).toBeVisible()
|
||||
const gutterBox = await gutter.boundingBox()
|
||||
if (!gutterBox) {
|
||||
throw new Error('sidebar splitter gutter has no bounding box')
|
||||
}
|
||||
await comfyPage.page.mouse.move(
|
||||
gutterBox.x + gutterBox.width / 2,
|
||||
gutterBox.y + gutterBox.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(520)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tab.selectionFooter.boundingBox()
|
||||
const side = await sidebar.boundingBox()
|
||||
return bar && side ? side.width - bar.width : 0
|
||||
})
|
||||
.toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,8 +832,7 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
// Queue polling also calls /jobs, so wait for completed history only.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
@@ -1002,9 +1000,7 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
@@ -1040,12 +1036,9 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
@@ -1056,12 +1049,10 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -214,7 +214,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.open()
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b1 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
@@ -222,7 +222,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.getAssetCardByName('beta').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - add node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking a model defers creation until placed on the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
|
||||
test('Ghost preview shows the model in the loader widget before placing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ConsoleMessage } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
@@ -95,4 +98,225 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
|
||||
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
|
||||
|
||||
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
|
||||
// widening the race window so a guard regression deterministically surfaces.
|
||||
async function deferLegacyHandlers(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const graph = window.app!.graph!
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const originalNodeRemoved = graph.onNodeRemoved
|
||||
const originalSelectionChange = canvas.onSelectionChange
|
||||
graph.onNodeRemoved = function (node) {
|
||||
queue.push(() => originalNodeRemoved?.call(this, node))
|
||||
}
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => originalSelectionChange?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
graph.onNodeRemoved = originalNodeRemoved
|
||||
canvas.onSelectionChange = originalSelectionChange
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
|
||||
|
||||
// Defers only the legacy selection-change callback, so the detached host
|
||||
// node lingers in the reactive selection while onNodeRemoved still runs
|
||||
// normally and clears it from the canvas. This isolates the panel render
|
||||
// path: a panel mounted during this window reads the stale selection.
|
||||
async function deferSelectionChange(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<DeferredHandlers> {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const original = canvas.onSelectionChange
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => original?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
canvas.onSelectionChange = original
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isNullGraphErrorText(text: string): boolean {
|
||||
return text.includes('NullGraphError') || text.endsWith('has no graph')
|
||||
}
|
||||
|
||||
// Vue's default errorHandler routes render throws to console.error,
|
||||
// not pageerror - listen to both.
|
||||
function captureNullGraphErrors(comfyPage: ComfyPage) {
|
||||
const captured: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
if (
|
||||
err.name === 'NullGraphError' ||
|
||||
isNullGraphErrorText(err.message ?? '')
|
||||
) {
|
||||
captured.push(`pageerror ${err.name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const onConsoleMessage = (msg: ConsoleMessage) => {
|
||||
if (msg.type() !== 'error') return
|
||||
const text = msg.text()
|
||||
if (isNullGraphErrorText(text)) {
|
||||
captured.push(`console.error: ${text}`)
|
||||
}
|
||||
}
|
||||
comfyPage.page.on('pageerror', onPageError)
|
||||
comfyPage.page.on('console', onConsoleMessage)
|
||||
return {
|
||||
getErrors: () => [...captured],
|
||||
stop: () => {
|
||||
comfyPage.page.off('pageerror', onPageError)
|
||||
comfyPage.page.off('console', onConsoleMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
}
|
||||
|
||||
async function reopenRightSidePanel(comfyPage: ComfyPage) {
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await expect(propertiesPanel.root).toBeHidden()
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Unpacks the subgraph behind deferred teardown, runs an optional
|
||||
// interaction while the node is detached but not yet cleaned up, then
|
||||
// drains the deferred handlers and reports any NullGraphErrors seen.
|
||||
async function unpackAndCaptureNullGraphErrors(
|
||||
comfyPage: ComfyPage,
|
||||
options: {
|
||||
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
|
||||
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
const errors = captureNullGraphErrors(comfyPage)
|
||||
const deferred = await options.defer(comfyPage)
|
||||
try {
|
||||
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
await options.duringWindow?.(comfyPage)
|
||||
await deferred.evaluate((handlers) => handlers.drain())
|
||||
// Let drained-handler reactive flushes settle before stop().
|
||||
await comfyPage.nextFrame()
|
||||
return errors.getErrors()
|
||||
} finally {
|
||||
await deferred.evaluate((handlers) => handlers.restore())
|
||||
await deferred.dispose()
|
||||
errors.stop()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const fixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await fixture.header.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'LGraphNode render path: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -280,3 +280,36 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Vue Node Group Context Menu',
|
||||
{ tag: ['@vue-nodes', '@canvas'] },
|
||||
() => {
|
||||
test('right-clicking a group opens the Vue context menu instead of the legacy menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Deselect so the right-click selects the group itself.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => graph!.groups.length))
|
||||
.toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y, {
|
||||
button: 'right'
|
||||
})
|
||||
|
||||
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
await expect(comfyPage.contextMenu.litegraphMenu).toBeHidden()
|
||||
|
||||
// Group-only action confirms it is the group menu.
|
||||
await expect(
|
||||
comfyPage.contextMenu.primeVueMenu.getByText('Fit Group To Nodes')
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -335,6 +335,30 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
})
|
||||
|
||||
test('pointerCancel stops autopan', async ({ comfyPage }) => {
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.header.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
|
||||
const getOffset = () => comfyPage.canvasOps.getOffset()
|
||||
const initialOffset = await getOffset()
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
|
||||
|
||||
await test.step('move outside pan range and cancel drag', async () => {
|
||||
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
|
||||
await ksampler.header.evaluate((node) =>
|
||||
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
|
||||
)
|
||||
})
|
||||
|
||||
const secondaryOffset = await getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.2",
|
||||
"version": "1.47.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
|
||||
import boundingBoxes from '@/locales/en/main.json'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBoxes: boundingBoxes.boundingBoxes,
|
||||
palette: { swatchTitle: 'Edit', addColor: 'Add' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
x: 51,
|
||||
y: 51,
|
||||
width: 256,
|
||||
height: 256,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
|
||||
...over
|
||||
})
|
||||
|
||||
const fakeCtx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function prepCanvas(canvas: HTMLCanvasElement) {
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
canvas.getContext = (() =>
|
||||
fakeCtx) as unknown as HTMLCanvasElement['getContext']
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
canvas.setPointerCapture = () => {}
|
||||
canvas.releasePointerCapture = () => {}
|
||||
}
|
||||
|
||||
function renderWidget(modelValue: BoundingBox[]) {
|
||||
const result = render(WidgetBoundingBoxes, {
|
||||
props: { nodeId: '1', modelValue },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
|
||||
prepCanvas(canvas)
|
||||
return { ...result, canvas }
|
||||
}
|
||||
|
||||
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0] as BoundingBox[]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('WidgetBoundingBoxes', () => {
|
||||
it('renders the canvas and editor shell', () => {
|
||||
renderWidget([])
|
||||
expect(
|
||||
screen.getByTestId('bounding-boxes').querySelector('canvas')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the region editor panel when a region is active', () => {
|
||||
renderWidget([box()])
|
||||
expect(screen.getByText('obj')).toBeTruthy()
|
||||
expect(screen.getByText('text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reveals the text field after switching the region to text', async () => {
|
||||
renderWidget([box()])
|
||||
expect(
|
||||
screen.queryByPlaceholderText('text to render (verbatim)')
|
||||
).toBeNull()
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
expect(
|
||||
screen.getByPlaceholderText('text to render (verbatim)')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears all regions via the clear button', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('Clear all'))
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('draws a region through canvas pointer events', async () => {
|
||||
const { canvas, emitted } = renderWidget([])
|
||||
await fireEvent.pointerDown(canvas, {
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerMove(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerUp(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(lastBoxes(emitted)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks focus and blur on the canvas', async () => {
|
||||
const { canvas } = renderWidget([box()])
|
||||
await fireEvent.focus(canvas)
|
||||
await fireEvent.blur(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens an inline editor on double click', async () => {
|
||||
const { canvas, container } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
expect(container.querySelector('textarea')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('syncs description edits back to the model', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('description of this region'),
|
||||
'a caption'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
|
||||
})
|
||||
|
||||
it('edits the text field once the region is a text region', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('text to render (verbatim)'),
|
||||
'hello'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
|
||||
})
|
||||
|
||||
it('deletes the active region with the Delete key', async () => {
|
||||
const { canvas, emitted } = renderWidget([box()])
|
||||
await fireEvent.keyDown(canvas, { key: 'Delete' })
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('clears hover state on pointer leave', async () => {
|
||||
const { canvas } = renderWidget([
|
||||
box({ x: 10, y: 10, width: 256, height: 256 })
|
||||
])
|
||||
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
|
||||
await fireEvent.pointerLeave(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits the inline editor on blur', async () => {
|
||||
const { canvas, container, emitted } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
const editor = container.querySelector('textarea')!
|
||||
await fireEvent.update(editor, 'committed')
|
||||
await fireEvent.blur(editor)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
})
|
||||
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands flex size-full flex-col gap-1 select-none"
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeRegion"
|
||||
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'obj'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('obj')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeObj') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'text'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('text')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeText') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeRegion.type === 'text'"
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.textLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.text"
|
||||
:placeholder="$t('boundingBoxes.textPlaceholder')"
|
||||
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.descLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.desc"
|
||||
:placeholder="$t('boundingBoxes.descPlaceholder')"
|
||||
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0 truncate text-sm text-muted-foreground">
|
||||
{{ $t('boundingBoxes.colors') }}
|
||||
</span>
|
||||
<PaletteSwatchRow
|
||||
v-model="activeRegion.palette"
|
||||
:max="maxColors"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: string }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
|
||||
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
|
||||
|
||||
const {
|
||||
canvasStyle,
|
||||
canvasCursor,
|
||||
focused,
|
||||
activeRegion,
|
||||
hasRegions,
|
||||
inlineEditor,
|
||||
maxColors,
|
||||
onPointerDown,
|
||||
onCanvasPointerMove,
|
||||
onDocPointerUp,
|
||||
onPointerLeave,
|
||||
onDoubleClick,
|
||||
onCanvasKeyDown,
|
||||
onInlineKeyDown,
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
})
|
||||
</script>
|
||||
@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
|
||||
@@ -427,7 +427,6 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
@@ -453,16 +452,14 @@ onMounted(() => {
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
if (isCloud) {
|
||||
const timeSpentSeconds = Math.floor(
|
||||
(Date.now() - sessionStartTime.value) / 1000
|
||||
)
|
||||
const timeSpentSeconds = Math.floor(
|
||||
(Date.now() - sessionStartTime.value) / 1000
|
||||
)
|
||||
|
||||
useTelemetry()?.trackTemplateLibraryClosed({
|
||||
template_selected: templateWasSelected.value,
|
||||
time_spent_seconds: timeSpentSeconds
|
||||
})
|
||||
}
|
||||
useTelemetry()?.trackTemplateLibraryClosed({
|
||||
template_selected: templateWasSelected.value,
|
||||
time_spent_seconds: timeSpentSeconds
|
||||
})
|
||||
|
||||
originalOnClose()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
v-model="filters['global'].value"
|
||||
class="max-w-96"
|
||||
size="lg"
|
||||
autofocus
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -38,6 +39,23 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: mockWorkspaceApi
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -118,6 +136,8 @@ describe('UsageLogsTable', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -320,6 +340,20 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 7
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
it('renders credit_added event with correct detail template', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
||||
|
||||
@@ -99,7 +99,10 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
EventType,
|
||||
@@ -112,6 +115,9 @@ const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
@@ -138,10 +144,13 @@ const loadEvents = async () => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await customerEventService.getMyEvents({
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
})
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<NodeDragPreview />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
@@ -145,6 +147,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
@@ -464,6 +467,7 @@ useNodeBadge()
|
||||
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useGroupContextMenu()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
|
||||
97
src/components/graph/NodeDragPreview.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
|
||||
() => ({
|
||||
default: { template: '<div data-testid="node-preview" />' }
|
||||
})
|
||||
)
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
|
||||
|
||||
function moveMouse(clientX: number, clientY: number) {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
|
||||
}
|
||||
|
||||
function ghostElement() {
|
||||
return document.querySelector('[data-testid="node-preview"]')?.parentElement
|
||||
?.parentElement
|
||||
}
|
||||
|
||||
describe('NodeDragPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows no ghost when nothing is being dragged', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('keeps the ghost hidden until the mouse position is known', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('follows the mouse with an offset while dragging', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
moveMouse(300, 400)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
|
||||
})
|
||||
|
||||
it('removes the ghost when the drag is cancelled', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
expect(ghostElement()).toBeTruthy()
|
||||
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
57
src/components/graph/NodeDragPreview.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGhost && rafPosition"
|
||||
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
|
||||
:style="{
|
||||
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview
|
||||
:node-def="draggedNode!"
|
||||
:widget-values="pendingWidgetValues"
|
||||
position="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouse, useRafFn } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
|
||||
|
||||
const { x, y, sourceType } = useMouse({ type: 'client' })
|
||||
|
||||
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
|
||||
const rafPosition = shallowRef<{ x: number; y: number }>()
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (sourceType.value === null) return
|
||||
const pos = rafPosition.value
|
||||
if (pos && pos.x === x.value && pos.y === y.value) return
|
||||
rafPosition.value = { x: x.value, y: y.value }
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
showGhost,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
rafPosition.value = undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
126
src/components/hdr/HdrViewerContent.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdrViewerContent from './HdrViewerContent.vue'
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
|
||||
|
||||
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
|
||||
vi.mock('@/composables/useHdrViewer', () => ({
|
||||
useHdrViewer: () => holder.viewer,
|
||||
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { loading: 'Loading', downloadImage: 'Download' },
|
||||
hdrViewer: {
|
||||
failedToLoad: 'Failed',
|
||||
exposure: 'Exposure',
|
||||
normalizeExposure: 'Auto exposure',
|
||||
channel: 'Channel',
|
||||
channels: {
|
||||
rgb: 'RGB',
|
||||
r: 'R',
|
||||
g: 'G',
|
||||
b: 'B',
|
||||
a: 'Alpha',
|
||||
luminance: 'Luminance'
|
||||
},
|
||||
sourceGamut: 'Source gamut',
|
||||
dither: 'Dither',
|
||||
clipWarnings: 'Clip warnings',
|
||||
fitView: 'Fit',
|
||||
histogram: 'Histogram',
|
||||
resolution: 'Resolution',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
mean: 'Mean',
|
||||
stdDev: 'Std dev',
|
||||
nan: 'NaN',
|
||||
inf: 'Inf'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeViewer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
exposureStops: ref(0),
|
||||
dither: ref(true),
|
||||
clipWarnings: ref(false),
|
||||
gamut: ref('sRGB'),
|
||||
channel: ref('r'),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
dimensions: ref('512 x 512'),
|
||||
stats: ref({
|
||||
min: 0,
|
||||
max: 4,
|
||||
mean: 0.5,
|
||||
stdDev: 0.2,
|
||||
nanCount: 2,
|
||||
infCount: 1
|
||||
}),
|
||||
histogram: ref(new Uint32Array([1, 2, 3, 4])),
|
||||
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
|
||||
mount: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
fitView: vi.fn(),
|
||||
normalizeExposure: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
return render(HdrViewerContent, {
|
||||
props: { imageUrl: '/api/view?filename=out.exr' },
|
||||
global: { plugins: [i18n], stubs: { Button: true } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('HdrViewerContent', () => {
|
||||
beforeEach(() => {
|
||||
holder.viewer = makeViewer()
|
||||
})
|
||||
|
||||
it('renders the full statistics set including NaN/Inf', () => {
|
||||
renderViewer()
|
||||
for (const label of [
|
||||
'Resolution',
|
||||
'Min',
|
||||
'Max',
|
||||
'Mean',
|
||||
'Std dev',
|
||||
'NaN',
|
||||
'Inf'
|
||||
]) {
|
||||
screen.getByText(label)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the pixel readout when a pixel is hovered', () => {
|
||||
renderViewer()
|
||||
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('colors the histogram according to the selected channel', () => {
|
||||
holder.viewer = makeViewer({ channel: ref('g') })
|
||||
const { container } = renderViewer()
|
||||
const path = container.querySelector('svg path')
|
||||
expect(path?.getAttribute('class')).toContain('text-green-500')
|
||||
})
|
||||
|
||||
it('renders an option for each channel mode', () => {
|
||||
renderViewer()
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Luminance' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
258
src/components/hdr/HdrViewerContent.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="flex size-full bg-base-background">
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute size-full"
|
||||
data-testid="hdr-viewer-canvas"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="viewer.loading.value"
|
||||
class="absolute inset-0 flex items-center justify-center text-base-foreground"
|
||||
>
|
||||
{{ $t('g.loading') }}...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="viewer.error.value"
|
||||
role="alert"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--image-off] size-12" />
|
||||
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.pixel.value"
|
||||
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
|
||||
data-testid="hdr-pixel-readout"
|
||||
>
|
||||
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
|
||||
<div>
|
||||
{{ formatNum(viewer.pixel.value.r) }}
|
||||
{{ formatNum(viewer.pixel.value.g) }}
|
||||
{{ formatNum(viewer.pixel.value.b) }}
|
||||
<template v-if="viewer.pixel.value.a !== null">
|
||||
{{ formatNum(viewer.pixel.value.a) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
|
||||
<input
|
||||
v-model.number="viewer.exposureStops.value"
|
||||
type="range"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
:aria-label="$t('hdrViewer.exposure')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
@click="viewer.normalizeExposure"
|
||||
>
|
||||
{{ $t('hdrViewer.normalizeExposure') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.channel') }}</label>
|
||||
<select
|
||||
v-model="viewer.channel.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.channel')"
|
||||
>
|
||||
<option v-for="mode in channelModes" :key="mode" :value="mode">
|
||||
{{ channelLabels[mode] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
|
||||
<select
|
||||
v-model="viewer.gamut.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.sourceGamut')"
|
||||
>
|
||||
<option v-for="name in gamutNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-dither"
|
||||
v-model="viewer.dither.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-dither" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.dither') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-clip"
|
||||
v-model="viewer.clipWarnings.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-clip" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.clipWarnings') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="histogramPath" class="space-y-2 p-2">
|
||||
<label>{{ $t('hdrViewer.histogram') }}</label>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
|
||||
>
|
||||
<path
|
||||
:d="histogramPath"
|
||||
:class="histogramColorClass"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.5"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.stats.value"
|
||||
class="space-y-1 p-2 text-xs tabular-nums"
|
||||
>
|
||||
<div v-if="viewer.dimensions.value" class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.resolution') }}</span>
|
||||
<span>{{ viewer.dimensions.value }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.min') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.min) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.max') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.max) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.mean') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.stdDev') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.nanCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.nan') }}</span>
|
||||
<span>{{ viewer.stats.value.nanCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.infCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.inf') }}</span>
|
||||
<span>{{ viewer.stats.value.infCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
|
||||
{{ $t('hdrViewer.fitView') }}
|
||||
</Button>
|
||||
<Button variant="secondary" class="flex-1" @click="handleDownload">
|
||||
{{ $t('g.downloadImage') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ChannelMode } from '@/composables/useHdrViewer'
|
||||
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
|
||||
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
|
||||
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
|
||||
const { imageUrl } = defineProps<{ imageUrl: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const viewer = useHdrViewer()
|
||||
const gamutNames = GAMUT_NAMES
|
||||
const channelModes = CHANNEL_MODES
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const exposureLabel = computed(() => {
|
||||
const value = viewer.exposureStops.value
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
|
||||
)
|
||||
|
||||
const histogramColorClass = computed(() => {
|
||||
switch (viewer.channel.value) {
|
||||
case 'r':
|
||||
return 'text-red-500'
|
||||
case 'g':
|
||||
return 'text-green-500'
|
||||
case 'b':
|
||||
return 'text-blue-500'
|
||||
default:
|
||||
return 'text-base-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
|
||||
rgb: t('hdrViewer.channels.rgb'),
|
||||
r: t('hdrViewer.channels.r'),
|
||||
g: t('hdrViewer.channels.g'),
|
||||
b: t('hdrViewer.channels.b'),
|
||||
a: t('hdrViewer.channels.a'),
|
||||
luminance: t('hdrViewer.channels.luminance')
|
||||
}))
|
||||
|
||||
function formatNum(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
|
||||
? value.toExponential(3)
|
||||
: value.toFixed(4)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadFile(toFullResolutionUrl(imageUrl))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
|
||||
})
|
||||
</script>
|
||||
70
src/components/palette/PaletteSwatchRow.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderRow(modelValue: string[], max = 5) {
|
||||
return render(PaletteSwatchRow, {
|
||||
props: { modelValue, max },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0]
|
||||
}
|
||||
|
||||
describe('PaletteSwatchRow', () => {
|
||||
it('renders one swatch per color', () => {
|
||||
const { container } = renderRow(['#ff0000', '#00ff00'])
|
||||
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
it('removes a color on right click', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
|
||||
expect(lastEmit(emitted)).toEqual(['#00ff00'])
|
||||
})
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
|
||||
button: 0,
|
||||
clientX: 5,
|
||||
clientY: 5
|
||||
})
|
||||
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
48
src/components/palette/PaletteSwatchRow.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
|
||||
:title="t('palette.addColor')"
|
||||
@click="addColor"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
54
src/components/palette/WidgetColors.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetColors from './WidgetColors.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderWidget(modelValue: string[], widget?: { name: string }) {
|
||||
return render(WidgetColors, {
|
||||
props: { modelValue, widget },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.()
|
||||
})
|
||||
|
||||
describe('WidgetColors', () => {
|
||||
it('renders the palette swatch row for each color', () => {
|
||||
renderWidget(['#ff0000', '#00ff00'])
|
||||
const root = screen.getByTestId('colors')
|
||||
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the widget name as an inline label', () => {
|
||||
renderWidget(['#ff0000'], { name: 'color_palette' })
|
||||
expect(screen.getByText('color_palette')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits an updated palette when a color is added', async () => {
|
||||
const { emitted } = renderWidget([])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
const calls = emitted()['update:modelValue'] as unknown[][]
|
||||
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
|
||||
})
|
||||
|
||||
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
|
||||
const { container } = renderWidget(['#ff0000'])
|
||||
const onDocMove = vi.fn()
|
||||
document.addEventListener('pointermove', onDocMove)
|
||||
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
|
||||
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
|
||||
expect(onDocMove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
29
src/components/palette/WidgetColors.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full items-center gap-2"
|
||||
data-testid="colors"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span
|
||||
v-if="widget?.name"
|
||||
class="shrink-0 truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</span>
|
||||
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const MAX_COLORS = 16
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string[]>({ default: () => [] })
|
||||
</script>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
@@ -165,7 +166,9 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
)
|
||||
@@ -175,7 +178,9 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
@@ -23,7 +24,7 @@ type Story = StoryObj<typeof meta>
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -37,7 +38,7 @@ const singleErrorCard: ErrorCardData = {
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: '24',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -55,7 +56,7 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: '45',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -75,7 +76,7 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: '3:15',
|
||||
nodeId: createNodeExecutionId([3, 15]),
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
||||
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
||||
@@ -156,7 +157,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
return {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -249,7 +250,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -387,7 +388,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const card: ErrorCardData = {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
export interface ErrorItem extends ResolvedErrorMessage {
|
||||
/** Raw source/API-compatible message. */
|
||||
@@ -12,7 +13,7 @@ export interface ErrorItem extends ResolvedErrorMessage {
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: string
|
||||
nodeId?: NodeExecutionId
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
|
||||
@@ -671,6 +671,30 @@ describe('useErrorGroups', () => {
|
||||
expect(nodeIds).toEqual(['1', '2', '10'])
|
||||
})
|
||||
|
||||
it('marks only nested execution paths as subgraph node cards', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1:20': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.cards).toMatchObject([
|
||||
{ nodeId: '1', isSubgraphNode: false },
|
||||
{ nodeId: '1:20', isSubgraphNode: true }
|
||||
])
|
||||
})
|
||||
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
resolveRunErrorMessage
|
||||
} from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
compareExecutionId,
|
||||
tryNormalizeNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/** Resolve display info for a node by its execution ID. */
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
function resolveNodeInfo(nodeId: NodeExecutionId) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
return {
|
||||
@@ -119,7 +119,7 @@ function getOrCreateGroup(
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: string,
|
||||
nodeId: NodeExecutionId,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
@@ -130,7 +130,7 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
isSubgraphNode: nodeId.includes(':'),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
@@ -288,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -305,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: string,
|
||||
nodeId: NodeExecutionId,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
error: CataloguedErrorItem,
|
||||
@@ -371,9 +371,11 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
for (const e of nodeError.errors) {
|
||||
@@ -404,9 +406,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
|
||||
if (!nodeId) return
|
||||
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
nodeId,
|
||||
e.node_type,
|
||||
'exec',
|
||||
{
|
||||
@@ -417,8 +422,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
@@ -669,7 +673,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
}
|
||||
|
||||
function isAssetErrorInSelection(executionNodeId: string): boolean {
|
||||
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -691,12 +695,17 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return false
|
||||
}
|
||||
|
||||
function isAssetCandidateInSelection(nodeId: string | number): boolean {
|
||||
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
|
||||
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
@@ -707,7 +716,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
|
||||
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
async function flushPromises() {
|
||||
@@ -103,7 +104,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
|
||||
return {
|
||||
id: 'card-1',
|
||||
title: 'KSampler',
|
||||
nodeId: '42',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
@@ -181,7 +182,7 @@ describe('useErrorReport', () => {
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA oom',
|
||||
traceback: 'trace-0',
|
||||
nodeId: '42',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeType: 'KSampler',
|
||||
systemStats: sampleSystemStats,
|
||||
serverLogs: 'server logs',
|
||||
|
||||
@@ -3,17 +3,12 @@ import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
type SidebarIconProps = {
|
||||
icon: string
|
||||
selected: boolean
|
||||
tooltip?: string
|
||||
class?: string
|
||||
iconBadge?: string | (() => string | null)
|
||||
}
|
||||
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -84,4 +79,20 @@ describe('SidebarIcon', () => {
|
||||
tooltipText
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to label for tooltip when no tooltip is provided', async () => {
|
||||
const labelText = 'WASNodeSuitePreprocessors'
|
||||
const { user } = renderSidebarIcon({ label: labelText })
|
||||
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
|
||||
|
||||
await user.hover(screen.getByRole('button'))
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,9 +40,11 @@
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<!-- w-max sizes the label to the rail instead of the padding-inset
|
||||
button content box, which is too narrow for one-line labels -->
|
||||
<span
|
||||
v-if="label && !isSmall"
|
||||
class="side-bar-button-label text-center text-2xs"
|
||||
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
|
||||
>{{ st(label, label) }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -83,7 +85,14 @@ const overlayValue = computed(() =>
|
||||
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
||||
)
|
||||
const shouldShowBadge = computed(() => !!overlayValue.value)
|
||||
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
/**
|
||||
* Falls back to the label when no tooltip is provided, so labels clamped
|
||||
* to two lines can always be recovered in full on hover.
|
||||
*/
|
||||
const computedTooltip = computed(() => {
|
||||
const text = tooltip || label
|
||||
return st(text, text) + tooltipSuffix
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -115,69 +115,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
<MediaAssetSelectionBar
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
class="flex h-18 w-full items-center justify-between gap-1"
|
||||
>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<Button
|
||||
variant="secondary"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
>
|
||||
{{
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
:count="totalOutputCount"
|
||||
:show-delete="shouldShowDeleteButton"
|
||||
@deselect="handleDeselectAll"
|
||||
@download="handleDownloadSelected"
|
||||
@delete="handleDeleteSelected"
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaLightbox
|
||||
@@ -208,8 +153,6 @@
|
||||
import {
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
@@ -236,6 +179,7 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
@@ -257,7 +201,6 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
@@ -335,33 +278,6 @@ const {
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const footerWidth = ref(0)
|
||||
|
||||
// Track footer width changes
|
||||
useResizeObserver(footerRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
footerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold matches when grid switches from 2 columns to 1 column
|
||||
// 2 columns need about ~430px
|
||||
const COMPACT_MODE_THRESHOLD_PX = 430
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
const selectionCountButtonRef = ref<HTMLElement | null>(null)
|
||||
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
@@ -429,6 +345,10 @@ const previewableVisibleAssets = computed(() =>
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const totalOutputCount = computed(() =>
|
||||
getTotalOutputCount(selectedAssets.value)
|
||||
)
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
captureRoot,
|
||||
getRoot,
|
||||
resetRoot,
|
||||
mockAddNodeOnGraph,
|
||||
mockStartDrag,
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
resetRoot: () => {
|
||||
capturedRoot = null
|
||||
},
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -37,8 +37,8 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
@@ -173,16 +173,13 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles model click and adds node to graph', async () => {
|
||||
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
|
||||
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
const mockWidget = { name: 'ckpt_name', value: '' }
|
||||
const mockGraphNode = { widgets: [mockWidget] }
|
||||
|
||||
mockGetNodeProvider.mockReturnValue({
|
||||
nodeDef: mockNodeDef,
|
||||
key: 'ckpt_name'
|
||||
})
|
||||
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
@@ -198,8 +195,10 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
await modelLeaf?.handleClick?.(mockEvent)
|
||||
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
|
||||
expect(mockWidget.value).toBe('model.safetensors')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' },
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles folder expansion on click', async () => {
|
||||
|
||||
@@ -63,10 +63,9 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
import { ResourceState, useModelStore } from '@/stores/modelStore'
|
||||
@@ -156,15 +155,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
)
|
||||
if (widget) {
|
||||
widget.value = model.file_name
|
||||
}
|
||||
startModelLoaderDrag(provider, model.file_name)
|
||||
}
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
|
||||
@@ -31,11 +31,8 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
isDragging: { value: false },
|
||||
draggedNode: { value: null },
|
||||
cursorPosition: { value: { x: 0, y: 0 } },
|
||||
startDrag: vi.fn(),
|
||||
cancelDrag: vi.fn(),
|
||||
setupGlobalListeners: vi.fn(),
|
||||
cleanupGlobalListeners: vi.fn()
|
||||
cancelDrag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="hasNoMatches"
|
||||
@@ -215,7 +214,6 @@ import type {
|
||||
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
|
||||
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
|
||||
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggedNode && showPreview"
|
||||
class="pointer-events-none fixed z-10000"
|
||||
:style="{
|
||||
left: `${previewPosition.x + 12}px`,
|
||||
top: `${previewPosition.y + 12}px`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview :node-def="draggedNode" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
const nativeDragPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const previewPosition = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value
|
||||
}
|
||||
return cursorPosition.value
|
||||
})
|
||||
|
||||
const showPreview = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
nativeDragPosition.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupGlobalListeners()
|
||||
document.addEventListener('drag', handleDrag)
|
||||
document.addEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGlobalListeners()
|
||||
document.removeEventListener('drag', handleDrag)
|
||||
document.removeEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
</script>
|
||||
110
src/components/ui/credit-slider/CreditSlider.stories.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const meta: Meta<typeof CreditSlider> = {
|
||||
title: 'Components/CreditSlider',
|
||||
component: CreditSlider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
// Previews at the real layout width: the Figma "Team Plan" card column is
|
||||
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
|
||||
// width the slider actually renders into inside PricingTableWorkspace.
|
||||
template: '<div class="w-[512px] px-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
|
||||
// In production this comes from the API; here it shows the stops being driven
|
||||
// entirely through props rather than the hardcoded default constant.
|
||||
const apiTeamCreditStops = {
|
||||
default_stop_index: 2,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_200',
|
||||
credits: 42_200,
|
||||
yearly: { price_cents: 20_000, discount_percent: 0 }
|
||||
},
|
||||
{
|
||||
id: 'team_400',
|
||||
credits: 84_400,
|
||||
yearly: { price_cents: 38_000, discount_percent: 5 }
|
||||
},
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
yearly: { price_cents: 63_000, discount_percent: 10 }
|
||||
},
|
||||
{
|
||||
id: 'team_1400',
|
||||
credits: 295_400,
|
||||
yearly: { price_cents: 119_000, discount_percent: 15 }
|
||||
},
|
||||
{
|
||||
id: 'team_2500',
|
||||
credits: 527_500,
|
||||
yearly: { price_cents: 200_000, discount_percent: 20 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
|
||||
// The pre-discount list price is recovered as discounted / (1 - discount).
|
||||
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
|
||||
credits: s.credits,
|
||||
discountPercentYearly: s.yearly.discount_percent,
|
||||
usd: Math.round(
|
||||
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
|
||||
)
|
||||
}))
|
||||
|
||||
export const BackendDrivenStops: Story = {
|
||||
name: 'Backend-driven stops (props)',
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const defaultStopIndex = apiTeamCreditStops.default_stop_index
|
||||
const value = ref(mappedStops[defaultStopIndex].usd)
|
||||
return { args, value, mappedStops, defaultStopIndex }
|
||||
},
|
||||
template:
|
||||
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
208
src/components/ui/credit-slider/CreditSlider.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
usdPerMonth: 'USD / mo',
|
||||
billedYearly: '{total} Billed yearly',
|
||||
billedMonthly: 'Billed monthly',
|
||||
creditSliderSave: 'Save {percent}% ({amount})'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderSlider(props: Record<string, unknown> = {}) {
|
||||
return render(CreditSlider, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('CreditSlider', () => {
|
||||
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
|
||||
renderSlider()
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '4')
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
|
||||
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(1400)
|
||||
})
|
||||
|
||||
it('snaps to the previous fixed stop on ArrowLeft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(400)
|
||||
})
|
||||
|
||||
it('emits change with the full {index, usd, credits} payload', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({ modelValue: 700, onChange })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
index: 3,
|
||||
usd: 1400,
|
||||
credits: 295_400
|
||||
})
|
||||
})
|
||||
|
||||
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({
|
||||
modelValue: 700,
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': onUpdate,
|
||||
onChange
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
|
||||
renderSlider() // default $700 stop → 10% yearly discount
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 10% ($70)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'$7,560'
|
||||
)
|
||||
})
|
||||
|
||||
it('halves the discount and reads "billed monthly" when cycle=monthly (PRD)', async () => {
|
||||
renderSlider({ cycle: 'monthly' }) // default $700 stop → 10% yearly → 5% monthly
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$665')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 5% ($35)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'Billed monthly'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the fractional monthly discount at $400 (2.5%)', async () => {
|
||||
renderSlider({ modelValue: 400, cycle: 'monthly' })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$390')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 2.5% ($10)'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the discount UI at the 0% stop ($200)', async () => {
|
||||
renderSlider({ modelValue: 200 })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
|
||||
expect(
|
||||
screen.queryByTestId('credit-slider-original-price')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all five fixed credit stop labels', async () => {
|
||||
renderSlider({ modelValue: 700 })
|
||||
await flush()
|
||||
|
||||
const stops = within(screen.getByTestId('credit-slider-stops'))
|
||||
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
|
||||
expect(stops.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
|
||||
const stops = [
|
||||
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
|
||||
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
|
||||
]
|
||||
// No modelValue → the model default ($700) matches no stop, so selectedIndex
|
||||
// falls back to defaultStopIndex (here index 1 → $100).
|
||||
renderSlider({ stops, defaultStopIndex: 1 })
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
|
||||
|
||||
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$100')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 25% ($25)'
|
||||
)
|
||||
|
||||
// Only the prop's labels render — none of the DES-197 defaults.
|
||||
const labels = within(screen.getByTestId('credit-slider-stops'))
|
||||
expect(labels.getByText('10.6K')).toBeInTheDocument()
|
||||
expect(labels.getByText('21.1K')).toBeInTheDocument()
|
||||
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
|
||||
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
|
||||
expect(stop.credits).toBe(usdToCredits(stop.usd))
|
||||
}
|
||||
})
|
||||
})
|
||||
235
src/components/ui/credit-slider/CreditSlider.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TransitionPresets,
|
||||
usePreferredReducedMotion,
|
||||
useTransition
|
||||
} from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: rootClass,
|
||||
stops = TEAM_PLAN_CREDIT_STOPS,
|
||||
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
cycle = 'yearly'
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
/**
|
||||
* The fixed credit stops the slider snaps to. Must be non-empty. Defaults to
|
||||
* the hardcoded DES-197 set; pass the backend-sourced stops once the contract
|
||||
* lands — map `GET /api/billing/plans → team_credit_stops.stops` to
|
||||
* `CreditStop[]` (credits, the pre-discount `usd`, and `discountPercentYearly`).
|
||||
*/
|
||||
stops?: readonly CreditStop[]
|
||||
/**
|
||||
* Stop selected when the bound value matches none (e.g. first render).
|
||||
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
|
||||
*/
|
||||
defaultStopIndex?: number
|
||||
/**
|
||||
* Billing cycle. Yearly applies the full `discountPercentYearly`; monthly
|
||||
* applies half of it (PRD: GA Team Billing — "for monthly the discount is
|
||||
* halved": yearly 0/5/10/15/20% → monthly 0/2.5/5/7.5/10%).
|
||||
*/
|
||||
cycle?: 'monthly' | 'yearly'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Fired when the selected stop changes, with the full derived payload. */
|
||||
change: [stop: { index: number; usd: number; credits: number }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* v-model carries the selected USD value (one of the `stops`). The literal
|
||||
* default keeps `defineModel` statically analyzable; when custom `stops` are
|
||||
* passed without a matching v-model, `selectedIndex` falls back to
|
||||
* `defaultStopIndex`, so the displayed stop is still correct.
|
||||
*/
|
||||
const usd = defineModel<number>({
|
||||
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
|
||||
})
|
||||
|
||||
const selectedIndex = computed(() => {
|
||||
const i = stops.findIndex((stop) => stop.usd === usd.value)
|
||||
if (i !== -1) return i
|
||||
// Fall back to the default stop, clamped into range: a backend-driven `stops`
|
||||
// array can be shorter than expected (or `defaultStopIndex` out of bounds), so
|
||||
// clamping keeps `current` defined and the price computeds below from reading
|
||||
// `undefined.usd` at runtime. (`stops` is required to be non-empty.)
|
||||
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
|
||||
})
|
||||
|
||||
const current = computed<CreditStop>(() => stops[selectedIndex.value])
|
||||
|
||||
// The discount applies to the monthly figure. Yearly uses the full
|
||||
// `discountPercentYearly`; monthly halves it (PRD: GA Team Billing). The card
|
||||
// shows the discounted monthly price, the struck pre-discount price, the
|
||||
// saving, and — for yearly — the annual total.
|
||||
const effectiveDiscountPercent = computed(() =>
|
||||
cycle === 'monthly'
|
||||
? current.value.discountPercentYearly / 2
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
/**
|
||||
* Smoothly count the price figures up/down as the slider moves between stops
|
||||
* instead of snapping. Honors the user's reduced-motion preference. The save
|
||||
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
|
||||
* tier, so animating the bracketed amount alone would read inconsistently.
|
||||
*/
|
||||
const prefersReducedMotion = usePreferredReducedMotion()
|
||||
const priceTween = {
|
||||
duration: 350,
|
||||
easing: TransitionPresets.easeOutCubic,
|
||||
disabled: computed(() => prefersReducedMotion.value === 'reduce')
|
||||
}
|
||||
const animatedMonthly = useTransition(discountedMonthly, priceTween)
|
||||
const animatedOriginal = useTransition(() => current.value.usd, priceTween)
|
||||
|
||||
const displayMonthly = computed(() => Math.round(animatedMonthly.value))
|
||||
const displayOriginal = computed(() => Math.round(animatedOriginal.value))
|
||||
// Derive the yearly total from the displayed monthly so it always reads as
|
||||
// exactly 12× the price shown — even mid-count — rather than drifting as a
|
||||
// second, independently-phased tween would.
|
||||
const displayBilledYearly = computed(() => displayMonthly.value * 12)
|
||||
|
||||
/**
|
||||
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
|
||||
* model. Driving the slider in index space with `step = 1` guarantees the
|
||||
* thumb can only land on the fixed stops — never a value in between.
|
||||
*/
|
||||
const sliderModel = computed<number[]>({
|
||||
get: () => [selectedIndex.value],
|
||||
set: ([index]) => {
|
||||
const stop = stops[index]
|
||||
if (!stop) return
|
||||
usd.value = stop.usd
|
||||
emit('change', { index, usd: stop.usd, credits: stop.credits })
|
||||
}
|
||||
})
|
||||
|
||||
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
|
||||
|
||||
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
|
||||
const formatCreditsCompact = (value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value)
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex w-full flex-col gap-3', rootClass)">
|
||||
<!-- Price: discounted monthly + struck pre-discount + save badge -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem]/none font-semibold text-base-foreground tabular-nums"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
class="text-base text-muted-foreground tabular-nums line-through"
|
||||
data-testid="credit-slider-original-price"
|
||||
>
|
||||
{{ formatUsd(displayOriginal) }}
|
||||
</span>
|
||||
<span class="text-base text-muted-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- Save badge: outlined primary pill. On wide layouts it's pushed to
|
||||
the right of the price; when the column narrows (mobile) it wraps
|
||||
and aligns left under the price instead (DES QA). -->
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
data-testid="credit-slider-save"
|
||||
class="shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background xl:ms-auto"
|
||||
>
|
||||
{{
|
||||
t('subscription.creditSliderSave', {
|
||||
percent: effectiveDiscountPercent,
|
||||
amount: formatUsd(saveAmount)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="m-0 text-sm text-muted-foreground tabular-nums"
|
||||
data-testid="credit-slider-billed-yearly"
|
||||
>
|
||||
{{
|
||||
cycle === 'monthly'
|
||||
? t('subscription.billedMonthly')
|
||||
: t('subscription.billedYearly', {
|
||||
total: formatUsd(displayBilledYearly)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
|
||||
<Slider
|
||||
v-model="sliderModel"
|
||||
:min="0"
|
||||
:max="lastIndex"
|
||||
:step="1"
|
||||
:disabled="disabled"
|
||||
range-class="bg-base-foreground"
|
||||
thumb-class="bg-base-foreground"
|
||||
/>
|
||||
|
||||
<!-- Credit stop labels; the selected stop is emphasized -->
|
||||
<ol
|
||||
data-testid="credit-slider-stops"
|
||||
class="m-0 flex list-none justify-between p-0"
|
||||
>
|
||||
<li
|
||||
v-for="(stop, i) in stops"
|
||||
:key="stop.usd"
|
||||
:data-selected="i === selectedIndex ? '' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 text-xs tabular-nums',
|
||||
i === selectedIndex
|
||||
? 'font-semibold text-base-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatCreditsCompact(stop.credits) }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,7 +15,11 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
SliderRootProps & { class?: HTMLAttributes['class'] }
|
||||
SliderRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
rangeClass?: HTMLAttributes['class']
|
||||
thumbClass?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const pressed = ref(false)
|
||||
@@ -25,7 +29,7 @@ const setPressed = (val: boolean) => {
|
||||
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'rangeClass', 'thumbClass')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -60,7 +64,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
class="absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
:class="
|
||||
cn(
|
||||
'absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
|
||||
props.rangeClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SliderTrack>
|
||||
|
||||
@@ -74,7 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
'cursor-grab',
|
||||
'before:absolute before:-inset-1 before:block before:rounded-full before:bg-transparent',
|
||||
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed }
|
||||
{ 'cursor-grabbing': pressed },
|
||||
props.thumbClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -225,6 +225,40 @@ describe('useAuthActions.reportError', () => {
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the signupBlocked message when the error carries the signup_blocked token', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
// The backend wraps the rejection in a generic code; we match the token in
|
||||
// the message, so it must win over the auth.errors.${code} fallback.
|
||||
reportError(
|
||||
new FirebaseError(
|
||||
'auth/internal-error',
|
||||
'Account creation is temporarily unavailable. (ref: signup_blocked)'
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.signupBlocked'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches the signup_blocked token case-insensitively', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(
|
||||
new FirebaseError('auth/internal-error', 'rejected: SIGNUP_BLOCKED')
|
||||
)
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.signupBlocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
|
||||
@@ -47,6 +47,19 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (
|
||||
error instanceof FirebaseError &&
|
||||
error.message.toLowerCase().includes('signup_blocked')
|
||||
) {
|
||||
// Match on `error.message`, not `error.code`: Firebase `beforeUserCreated`
|
||||
// rejections collapse the thrown code into a generic `auth/internal-error`,
|
||||
// so the message is the only reliable channel. `signup_blocked` is a
|
||||
// cross-repo contract token; matched case-insensitively.
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('auth.errors.signupBlocked')
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier
|
||||
SubscriptionTier,
|
||||
TeamCreditStops
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type BillingType = 'legacy' | 'workspace'
|
||||
@@ -71,6 +73,10 @@ export interface BillingState {
|
||||
balance: ComputedRef<BalanceInfo | null>
|
||||
plans: ComputedRef<Plan[]>
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
/** Team per-credit pricing ladder; null for personal/legacy. */
|
||||
teamCreditStops: ComputedRef<TeamCreditStops | null>
|
||||
/** The team's currently-subscribed credit stop; null for personal/legacy. */
|
||||
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
@@ -83,5 +89,10 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||