Compare commits

..

11 Commits

Author SHA1 Message Date
Michael B
99a01476a2 Added pricing section to the education page 2026-07-02 17:07:25 -04:00
Michael B
4c248eef4d Copy changes for ZH-CN 2026-07-02 16:57:44 -04:00
imick-io
2a71e32caf feat(website): pricing page — team plan, billing toggle, refactor (#13065)
## Summary

Comprehensive iteration of the `/cloud/pricing` page: adds a Team plan
with an interactive credit-pool slider, a monthly/yearly billing toggle
with per-plan savings, and refactors the page into focused
presentational components fed by data modules.

## Changes

- **What**:
- **New Team plan** rendered as a full-width card between the standard
tiers and the enterprise band, with a 5-stop credit slider (42.2K →
528K), reactive price/savings/billing-note based on the selected tier +
billing period.
- **Monthly/Yearly billing toggle** (default: Yearly). Standard tiers
show the discounted yearly-equivalent price with the original monthly
struck through; team card additionally shows a yellow `Save N% ($X)`
chip when a discount applies.
- **Pricing component extraction**: `PricingTeamCard.vue`,
`PricingEnterpriseBand.vue`, `PricingPrice.vue`, `PricingCredits.vue`,
`PricingPlanLabel.vue`, `PricingCard.vue`. `PricingSection.vue` shrank
from ~445 → ~140 lines and now just orchestrates.
- **Data extracted** to `src/data/pricingPlans.ts` (standard tier config
+ `SHOW_FREE_TIER` gate) and `src/data/teamCreditTiers.ts` (5 numeric
tiers with monthly/yearly/base prices and video estimates), following
the existing `mainNavigation.ts`/`learningTutorials.ts` conventions.
- **New shadcn primitives**: `Slider.vue` (with `ticks` prop that paints
inset dots aligned to thumb positions, in-range dots adopt the range
color), `ToggleGroup.vue`, `ToggleGroupItem.vue`. `Button.vue` defaults
`as` to `<a>` when `href` is passed.
- **WhatsIncludedSection** copy refresh: dropped the "Run Workflows via
API" row, rewrote feature2/5/8 copy, added two inline yellow links in
feature7, and rendered a yellow `(coming soon)` badge under the Parallel
job execution title.
- **Enterprise band** redesigned with new description copy and a
"Contact Us" CTA.
- **i18n**: ~30 new/updated keys (en + zh-CN) for billing periods,
billed-yearly/monthly notes, save-percent template, team-specific copy,
and the refreshed What's Included content.
- **Responsive**: team card stacks vertically below `lg:`, hides tick
icons on narrow widths so the credit labels fit, stacks
label+description.

## Review Focus

- The team `PricingTeamCard` owns its full state surface (slider index,
computeds, `fmtPrice`) — parent passes only `billingPeriod` + `locale`.
Discounts are derived per-tier from `basePrice` −
`monthlyPrice`/`yearlyPrice`.
- `PricingPrice` and `PricingCredits` own their own `t()` calls for the
parts that need interpolation (billing note, savings chip, video
estimate); leaf props for static text stay pre-translated. This split is
intentional — pre-translation for the easy cases, internal translation
for templated cases.
- Two `href="#"` placeholders in `pricing.included.feature7.description`
("Check out which nodes we support currently" / "See which nodes we're
working on adding") — need real URLs before merge.
- The legacy
`apps/website/src/components/product/cloud/PricingSection.vue` (used by
`/cloud` landing page) is **untouched** — only `components/pricing/` was
refactored.

## Screenshots

Verified at 1440 / 1024 / 1023 / 768 / 375 widths during development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-02 16:57:44 -04:00
Michael B
72633ea771 feat(website): add "How it works" steps section to Education page
Add StepsSplit01, a numbered-steps list paired with a full-height
cover-media panel (image or video) inside a GlassCard, with an optional
heading. Wire it into /edu and /zh-CN/edu between the hero and Q&A with
en + zh-CN step copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:57:44 -04:00
Michael B
e40f33831c feat(website): add education hero with video backdrop
Add HeroBackdrop01, a full-bleed hero block whose background can be an
image or video, with an overlaid text-width light gradient plus
backdrop-blur scrim so the copy stays legible over busy media. Wire it
into /edu and /zh-CN/edu above the Q&A section with en + zh-CN copy, and
cover the badge/headline with a smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:57:44 -04:00
Michael B
c6c85bf6c8 feat(website): add Education landing page with Q&A and CTA
Add the /edu page (and zh-CN/edu) wired with the existing FAQSplit01
and CtaCenter01 blocks, following the affiliates page pattern.
Includes educationFaq data (en + zh-CN), education.* i18n keys, the
education route, FAQ JSON-LD, and e2e coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:57:44 -04:00
Mobeen Abdullah
4cc0402325 revert(website): remove Creative Campus customer stories (#13370) (#13407)
## Summary

Reverts #13370 (the five Creative Campus customer stories) from `main`.
These are education-tied stories, and the "Education Program is live"
CTA links to the education page, which is not live yet, so they should
not be public before the education launch.

This is a clean `git revert` of the squash commit `49a90d4e2` (no
history rewrite, no force-push). No work is lost: the story branch
(`feat/website-customer-stories-education`) is intact, and the stories
will relaunch together with pricing and the education page via #13406.

## Changes

- **What**: Reverts the 5 new story MDX files, the new article block
components, and the related changes to `CustomerArticle.astro`,
`global.css`, `Figure`/`Quote`/`Contributors`, the content test, and the
e2e spec. The existing five stories and the customers pages are
unaffected.
- **Breaking**: none.

## Review Focus

- Pure inverse of #13370; the diff is `-858/+11` mirroring the original
merge.
- Files touched by #13370 are disjoint from the education-page work in
#13406, so this does not conflict with that branch.

## Verification

- Build: 497 pages (down 5 en story pages). Unit: 156/156. Typecheck: 0
errors. format:check and knip clean.

## Next steps

- Stories move into the education bundle (#13406) via a separate PR.
- When the education page and its auth (FE-1174) are ready, pricing +
customer stories + education launch together.
2026-07-03 01:49:47 +05:00
Wei Hai
a2adfe5124 fix(ci): drop unsupported 'range' genhtml ignore-errors category (#13396)
## Summary
- `CI: E2E Coverage`'s `Generate HTML coverage report` step fails on
every run with `genhtml: ERROR: unknown argument for --ignore-errors:
'range'`
- The runner's `apt-get install lcov` resolves to lcov 2.0-4ubuntu2
(Ubuntu 24.04/noble), but the `range` ignore-errors category was only
added in lcov 2.1
- lcov 2.0 already reports the out-of-range-line condition under the
`source` category, which is already in the ignore list, so `range` was
both unsupported and redundant on this runner

## Test plan
- [x] Confirmed lcov 2.0-4ubuntu2 is what `apt-get install lcov`
resolves to on `ubuntu-latest`
- [x] Confirmed via lcov's `lcovutil.pm` source that `range`
(`$ERROR_RANGE`) is only registered as of v2.1, and in v2.0 the
equivalent out-of-range case falls under `$ERROR_SOURCE`
- [ ] CI: E2E Coverage run on this branch's merge should pass the
"Generate HTML coverage report" step
2026-07-02 20:08:47 +00:00
Mobeen Abdullah
49a90d4e2e feat(website): add five Creative Campus customer stories (#13370)
## Summary

Add the five new Comfy Education Initiative (Creative Campus) customer
stories to `/customers`, each with its own detail page, reusing the
existing Astro content-collection pattern. Brings the listing to ten
stories. Linear: FE-1161.

## Changes

- **What**: Five new English MDX stories (Xindi Zhang, Ina Conradi,
Golan Levin, Kathy Smith, and the UAL CCI partnership) added to the
customers collection, ordered after the existing five. Adds a small set
of reusable article blocks these stories need: `Embed` (Vimeo), `Video`
(wraps the existing `VideoPlayer`), `Download` (workflow JSON),
`AuthorBio`, `EducationCta`, `AtAGlance`, a styled inline `Link`, and
`Heading4`. `Quote`'s `name` is now optional for unattributed
pull-quotes; `Figure` gained an optional rich-caption slot (for captions
that contain links); `AuthorBio` supports a single-author bio via slot.
- **Breaking**: none. All additions are backward compatible; the
existing five stories and their pages are untouched.
- **Dependencies**: none.

## Review Focus

- The logic to review is small and isolated: the new block components in
`components/customers/content/` and their registration in
`CustomerArticle.astro`. The rest of the diff is MDX content.
- **Story copy is transcribed verbatim from the source docs**;
punctuation (em/en dashes, curly quotes) is preserved as written and is
intentional, not a formatting slip.
- **Downloads (cross-origin):** the workflow JSON files are on
media.comfy.org, so the HTML `download` attribute is ignored by
browsers. The real download is forced server-side with
`Content-Disposition: attachment` on the storage objects. Xindi's two
workflow files are served from a cache-fresh `.../workflows/` path (with
an explicit `filename=`) so the CDN serves the attachment header
immediately.
- **Embed hardening:** the Vimeo `Embed` iframe carries
`referrerpolicy="strict-origin-when-cross-origin"` and a scoped
`sandbox` (`allow-scripts allow-same-origin allow-presentation
allow-popups`); the player was verified to still load and play.
- All media (card covers, inline images, one video with a poster frame,
workflow JSON/PNG downloads) is hosted on media.comfy.org. No local
assets are committed. Golan's workflow files are re-hosted there; his
lesson-plan and demo-project links intentionally stay on GitHub/p5.js as
view-only.
- English-first: Chinese versions will be added later through a separate
translation service. The listing and detail pages already handle a
locale that only has English entries, so no page-code changes were
needed.
- Tags: "Creative Campus Showcase" for the four teaching stories, and
"Creative Campus Partnership" for the UAL announcement.

## Verification

- Unit `176/176`, typecheck (astro check) `0 errors`, build `502 pages`,
`format:check`, `knip`, and `eslint` all pass.
- e2e customer specs `6/6` pass (includes a new test asserting the
Creative Campus education blocks render).
- Visual pass on all ten stories at desktop (1440) and mobile (390): no
horizontal overflow, the Vimeo player plays, and all downloads resolve
to media.comfy.org.

## Screenshots (if applicable)

Easiest way to review is the Vercel preview:
https://comfy-website-preview-pr-13370.vercel.app/customers then open
the five new stories. Verified on desktop (1440) and mobile (390).
2026-07-03 00:34:20 +05:00
Hunter
d6c582c399 feat(billing): gate consolidated billing behind consolidated_billing_enabled flag (#13359)
## Summary

Shields personal-workspace billing code paths behind the new
`consolidated_billing_enabled` feature flag so they fall back to the
**legacy** billing flow while the flag is `false`. Team workspaces are
unaffected and continue to use the workspace-scoped billing flow.

## Changes

- Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the
`consolidated_billing_enabled` server flag / remote config, defaults to
`false`) and to the `RemoteConfig` type.
- New `useBillingRouting` composable — a single source of truth for
whether the active workspace uses the workspace vs. legacy billing flow:
  - team workspaces disabled → legacy
  - personal workspace + consolidated billing off/missing → legacy
  - personal workspace + consolidated billing on → workspace
  - team workspace → workspace
  - workspace not loaded yet → legacy
- Route `useBillingContext` and the affected UI sites
(`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`,
`TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead
of keying on `teamWorkspacesEnabled` directly.
- Update the storybook `useFeatureFlags` mock to stay in sync.

## Testing

- `pnpm test:unit` for `useBillingRouting`, `useBillingContext`,
`useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage
for the routing matrix). Remaining quality gates (`typecheck`, `lint`)
are being verified in CI.

## Related

Requires the backend PR that adds the `consolidated_billing_enabled`
flag to `/api/features`.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-07-02 18:34:39 +00:00
imick-io
a6db1ab3d6 fix(website): restore node-link.svg intrinsic sizing (#13384)
## Summary

Restore the original `node-link.svg` asset, which PR #13095 accidentally
overwrote with a stretch-to-fill Figma export, breaking the node
connector across the marketing site.

## Changes

- **What**: Revert `apps/website/public/icons/node-link.svg` to its
intrinsic **20×32** form (`fill="#F2FF59"`). PR #13283 had replaced it
with a raw Figma export (`preserveAspectRatio="none"`, `width="100%"
height="100%"`, `fill="var(--fill-0, …)"`). Every consumer loads it as a
bare `<img src>` and relies on the intrinsic size plus
`scale-*`/`rotate` classes — with no intrinsic dimensions the connector
expanded to fill its container and distorted.

## Review Focus

- The overwrite originated in the first commit of #13283's stack and
rode through the squash merge; nothing in that PR actually referenced
this file (the MCP page uses the separate `NodeUnionIcon.vue`), so
restoring the shared asset fixes all consumers (`BuildWhatSection`,
`ProductShowcaseSection`, `OurValuesSection`, `GalleryDetailModal`)
without touching the MCP page.
- `apps/website/dist/icons/node-link.svg` is stale build output and
regenerates on the next `pnpm build`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-02 13:07:00 +00:00
92 changed files with 3097 additions and 4706 deletions

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--ignore-errors source,unmapped \
--synthesize-missing
- name: Upload HTML report artifact

View File

@@ -111,8 +111,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
// The FAQ accordion is a Reka (client:visible) island whose trigger already
// renders aria-expanded="false" server-side, so that attribute is not a
// reliable hydration gate. Re-click until the island has hydrated and the
// trigger actually toggles open.
await expect(async () => {
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
}).toPass()
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await firstQuestion.click()

View File

@@ -87,11 +87,11 @@ test.describe('Cloud page @smoke', () => {
await expect(cards).toHaveCount(3)
})
test('FAQSection heading is visible with 15 items', async ({ page }) => {
test('FAQSection heading is visible with 12 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(15)
await expect(faqButtons).toHaveCount(12)
})
})

View File

@@ -0,0 +1,137 @@
import { expect } from '@playwright/test'
import { educationFaqs } from '../src/data/educationFaq'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/edu'
const LEARNING_PATH = '/learning'
const PRICING_PATH = '/cloud/pricing'
const FAQ_COUNT = educationFaqs.length
const FIRST_FAQ = educationFaqs[0]
const HERO_TITLE_TEXT = t('education.hero.title', 'en').replace(/\s+/g, ' ')
const HERO_BADGE_TEXT = t('education.hero.badge', 'en')
const FAQ_HEADING_TEXT = t('education.faq.heading', 'en')
const CTA_HEADING_TEXT = t('education.cta.heading', 'en')
const CTA_CHOOSE_PLAN_LABEL = t('education.cta.choosePlan', 'en')
const CTA_START_LEARNING_LABEL = t('education.cta.startLearning', 'en')
const CTA_TERMS_LABEL = t('education.cta.termsLabel', 'en')
test.describe('Education landing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the hero badge and headline', async ({ page }) => {
await expect(page.getByText(HERO_BADGE_TEXT)).toBeVisible()
await expect(
page.getByRole('heading', { level: 1, name: HERO_TITLE_TEXT })
).toBeVisible()
})
test('renders the Q&A heading and is indexable', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 2, name: FAQ_HEADING_TEXT })
).toBeVisible()
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
})
test('renders the closing CTA heading and both buttons', async ({ page }) => {
const ctaSection = page.locator('section').filter({
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
})
const ctaHeading = ctaSection.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const choosePlan = ctaSection.getByRole('link', {
name: CTA_CHOOSE_PLAN_LABEL
})
await expect(choosePlan).toBeVisible()
await expect(choosePlan).toHaveAttribute('href', '#plans')
const startLearning = ctaSection.getByRole('link', {
name: CTA_START_LEARNING_LABEL
})
await expect(startLearning).toBeVisible()
await expect(startLearning).toHaveAttribute('href', LEARNING_PATH)
await expect(startLearning).not.toHaveAttribute('target', '_blank')
})
test('CTA section links to the pricing FAQs in the same tab', async ({
page
}) => {
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
await termsLink.scrollIntoViewIfNeeded()
await expect(termsLink).toBeVisible()
await expect(termsLink).toHaveAttribute('href', PRICING_PATH)
await expect(termsLink).not.toHaveAttribute('target', '_blank')
})
})
test.describe('Education landing — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('emits FAQPage structured data with one entry per FAQ', async ({
page
}) => {
const faqJsonLd = await page.evaluate(() => {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]'
)
)
const match = scripts.find((s) =>
(s.textContent ?? '').includes('FAQPage')
)
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('FAQ items toggle open and closed on click', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: FIRST_FAQ.question.en
})
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
})
})
test.describe('Education landing — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('closing CTA stays within the viewport width', async ({ page }) => {
const ctaHeading = page.getByRole('heading', {
level: 2,
name: CTA_HEADING_TEXT
})
await ctaHeading.scrollIntoViewIfNeeded()
await expect(ctaHeading).toBeVisible()
const box = await ctaHeading.boundingBox()
expect(box, 'CTA heading bounding box').not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1
)
})
})

View File

@@ -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
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -1,32 +1,20 @@
<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<{
id?: string
heading: string
faqs: readonly Faq[]
}>()
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
() => faqs.length,
(length) => {
if (length === expanded.length) return
expanded.length = 0
for (let i = 0; i < length; i += 1) expanded.push(false)
}
)
function toggle(index: number) {
expanded[index] = !expanded[index]
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<section :id class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
@@ -38,57 +26,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>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<video
v-if="backdrop?.type === 'video'"
:src="backdrop.src"
:poster="backdrop.poster"
:aria-label="backdrop.alt"
autoplay
loop
muted
playsinline
preload="metadata"
class="absolute inset-0 size-full object-cover"
/>
<img
v-else-if="backdrop?.type === 'image'"
:src="backdrop.src"
:alt="backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
class="absolute inset-0 size-full object-cover"
/>
</slot>
<div
class="relative flex min-h-[70svh] flex-col justify-center px-8 py-16 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 backdrop-blur-md lg:-inset-16"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-ink lg:text-6xl/tight"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-ink lg:text-lg"
>
{{ subtitle }}
</p>
<p v-if="footnote" class="mt-10 text-sm text-primary-comfy-ink">
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import GlassCard from '../common/GlassCard.vue'
import SectionHeader from '../common/SectionHeader.vue'
type Step = { id: string; title: string; description: string }
type Media =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
steps,
media,
heading,
mediaPosition = 'right',
class: className
} = defineProps<{
steps: readonly Step[]
media?: Media
heading?: string
mediaPosition?: 'left' | 'right'
class?: HTMLAttributes['class']
}>()
function stepNumber(index: number) {
return String(index + 1).padStart(2, '0')
}
</script>
<template>
<section :class="cn('max-w-9xl mx-auto px-6 py-16 lg:py-24', className)">
<SectionHeader v-if="heading" class="mb-12 lg:mb-16">
{{ heading }}
</SectionHeader>
<GlassCard>
<div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-2 lg:gap-8">
<ol class="flex flex-col gap-6 px-6 py-8 lg:px-10 lg:py-14">
<li v-for="(step, index) in steps" :key="step.id">
<p
class="font-formula-narrow text-primary-comfy-yellow text-sm font-bold tracking-wide uppercase lg:text-base"
>
{{ stepNumber(index) }} {{ step.title }}
</p>
<p
class="mt-2 text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ step.description }}
</p>
</li>
</ol>
<div
v-if="media || $slots.media"
:class="
cn(
'relative aspect-video overflow-hidden rounded-4xl lg:aspect-auto',
mediaPosition === 'left' && 'lg:order-first'
)
"
>
<slot name="media">
<video
v-if="media?.type === 'video'"
:src="media.src"
:poster="media.poster"
:aria-label="media.alt"
autoplay
loop
muted
playsinline
preload="metadata"
class="absolute inset-0 size-full object-cover"
/>
<img
v-else-if="media?.type === 'image'"
:src="media.src"
:alt="media.alt ?? ''"
decoding="async"
class="absolute inset-0 size-full object-cover"
/>
</slot>
</div>
</div>
</GlassCard>
</section>
</template>

View File

@@ -10,12 +10,14 @@ const {
locale = 'en',
headingKey,
faqPrefix,
faqCount
faqCount,
footerKey
} = defineProps<{
locale?: Locale
headingKey: TranslationKey
faqPrefix: string
faqCount: number
footerKey?: TranslationKey
}>()
const faqKeys: Array<{ q: TranslationKey; a: TranslationKey }> = Array.from(
@@ -45,9 +47,9 @@ function toggle(index: number) {
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
{{ t(headingKey, locale) }}
</h2>
</div>
@@ -57,7 +59,7 @@ function toggle(index: number) {
<div
v-for="(faq, index) in faqs"
:key="index"
class="border-primary-comfy-canvas/20 border-b"
class="border-b border-primary-comfy-canvas/20"
>
<button
:id="`faq-trigger-${index}`"
@@ -98,11 +100,18 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${index}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
{{ faq.answer }}
</p>
<p
class="[&_a]:text-primary-comfy-yellow text-sm whitespace-pre-line text-primary-comfy-canvas/70 [&_a]:underline"
v-html="faq.answer"
/>
</section>
</div>
<p
v-if="footerKey"
class="[&_a]:text-primary-comfy-yellow mt-8 text-sm text-primary-comfy-canvas/70 [&_a]:underline"
v-html="t(footerKey, locale)"
/>
</div>
</div>
</section>

View File

@@ -0,0 +1,23 @@
<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
id="faq"
:heading="t('pricing.faq.heading', locale)"
:faqs="faqs"
/>
</template>

View File

@@ -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>

View 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>

View 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 text-xs">
{{ estimate }}
</p>
</div>
</template>

View File

@@ -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>

View File

@@ -1,60 +1,78 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
import type {
PlanFeatureGroup,
PlanFeatureStatus
} from '../../data/pricingPlans'
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Clock, X } from '@lucide/vue'
import { t } from '../../i18n/translations'
interface PlanFeature {
text: TranslationKey
export type { PlanFeatureGroup }
const statusIcon = {
included: Check,
excluded: X,
coming: Clock
} as const
const statusIconClass: Record<PlanFeatureStatus, string> = {
included: 'text-primary-comfy-yellow',
excluded: 'text-primary-comfy-canvas/40',
coming: 'text-primary-warm-gray'
}
const {
features,
featureIntroKey,
nextUpKey,
andMoreKey,
nextUpClass = 'text-primary-comfy-canvas/80 mt-4 text-sm',
andMoreClass = 'text-primary-comfy-canvas mt-4 text-sm',
listGap = 'space-y-2',
introMargin = 'mb-3',
locale = 'en'
} = defineProps<{
features: PlanFeature[]
featureIntroKey?: TranslationKey
nextUpKey?: TranslationKey
andMoreKey?: TranslationKey
nextUpClass?: string
andMoreClass?: string
listGap?: string
introMargin?: string
const statusTextClass: Record<PlanFeatureStatus, string> = {
included: 'text-primary-warm-white',
excluded: 'text-primary-warm-gray',
coming: 'text-primary-warm-gray'
}
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"
>
<component
:is="statusIcon[feature.status ?? 'included']"
class="mt-0.5 size-4 shrink-0"
:class="statusIconClass[feature.status ?? 'included']"
aria-hidden="true"
/>
<span class="sr-only">
{{
t(
`pricing.plan.feature.status.${feature.status ?? 'included'}`,
locale
)
}}:
</span>
<span
class="ppformula-text-center text-sm"
:class="statusTextClass[feature.status ?? 'included']"
>
{{ t(feature.text, locale) }}
</span>
</li>
</ul>
</div>
</div>
</template>

View 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>

View 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>

View File

@@ -0,0 +1,142 @@
<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)"
variant="outline"
class="w-full text-center"
>
{{ t(plan.ctaKey, locale) }}
</Button>
</div>
</PricingCard>
<PricingTeamCard :billing-period="billingPeriod" :locale />
<PricingEnterpriseBand :locale />
</div>
<!-- Footnote -->
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
{{ t('pricing.footnote', locale) }}
</p>
</section>
</template>

View File

@@ -0,0 +1,159 @@
<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 {
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
// Round to 1 decimal so future tiers can't render repeating decimals
// (e.g. 8.333333%), while preserving exact values like 2.5% / 7.5%.
const pct = Math.round(((base - discounted) / base) * 1000) / 10
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', status: 'coming' },
{ text: 'pricing.plan.team.projects', status: '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"
>
<template #tick="{ index, active }">
<ComponentIcon
class="hidden size-4 shrink-0 lg:block"
:class="
active
? 'text-primary-comfy-orange'
: 'text-primary-warm-gray'
"
/>
<span
class="text-sm max-sm:text-[10px]"
:class="
active ? 'text-primary-warm-white' : 'text-primary-warm-gray'
"
>
{{ formatTeamCreditsShort(teamCreditTiers[index].credits) }}
</span>
</template>
</Slider>
</div>
<PricingCredits
:credits="selectedTeamTier.credits.toLocaleString('en-US')"
:label="t('pricing.creditsLabel', locale)"
estimate-key="pricing.team.videosEstimate"
:estimate-count="selectedTeamTier.videos.toLocaleString('en-US')"
:locale
/>
</div>
<div>
<PricingPlanFeatureList :features="featureGroups" :locale />
<div class="mt-8">
<Button :href="ctaHref" class="w-full" variant="outline">
{{ t('pricing.plan.team.cta', locale) }}
</Button>
</div>
</div>
</div>
</PricingCard>
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -11,6 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
:locale="locale"
heading-key="cloud.faq.heading"
faq-prefix="cloud.faq"
:faq-count="15"
:faq-count="12"
footer-key="cloud.faq.footer"
/>
</template>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -13,7 +13,9 @@ export const badgeVariants = cva({
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
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'
}
},
defaultVariants: {

View File

@@ -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">

View File

@@ -0,0 +1,112 @@
<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,
modelValue,
...restProps
} = defineProps<
SliderRootProps & { class?: HTMLAttributes['class']; ticks?: number }
>()
const emits = defineEmits<SliderRootEmits>()
const forwarded = useForwardPropsEmits(
computed(() => ({ ...restProps, modelValue, min, max })),
emits
)
// Single source of truth for tick geometry, shared by the on-track dots and the
// optional #tick label slot so the two can never drift apart.
function tickLeft(i: number): string {
return `calc(8px + ${(i - 1) / (ticks! - 1)} * (100% - 16px))`
}
function tickValue(i: number): number {
return min + ((i - 1) / (ticks! - 1)) * (max - min)
}
function isTickActive(i: number): boolean {
return modelValue != null && tickValue(i) === modelValue[0]
}
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',
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: tickLeft(i) }"
/>
</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"
>
<SliderRange
data-slot="slider-range"
class="bg-primary-warm-white absolute data-[orientation=horizontal]:h-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>
<div v-if="$slots.tick && ticks && ticks > 1" class="relative mt-3 h-6">
<div
v-for="i in ticks"
:key="i"
class="absolute top-0 inline-flex -translate-x-1/2 items-center gap-1.5"
:style="{ left: tickLeft(i) }"
>
<slot name="tick" :index="i - 1" :active="isTickActive(i)" />
</div>
</div>
</template>

View 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>

View File

@@ -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>

View 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 hover:text-primary-warm-white inline-flex items-center justify-center gap-2 rounded-xl text-xs font-bold whitespace-nowrap uppercase transition-[color,box-shadow] duration-300 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:text-primary-comfy-ink [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
'bg-transparency-white-t4 text-primary-warm-gray hover:cursor-pointer'
},
size: {
default: 'h-9 min-w-20 px-4'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)

View File

@@ -14,6 +14,7 @@ const baseRoutes = {
customers: '/customers',
demos: '/demos',
learning: '/learning',
education: '/edu',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',

View File

@@ -0,0 +1,166 @@
import type { LocalizedText } from '../i18n/translations'
interface EducationFaq {
id: string
question: LocalizedText
answer: LocalizedText
}
export const educationFaqs: readonly EducationFaq[] = [
{
id: 'what-discount',
question: {
en: 'What discount do I get?',
'zh-CN': '我能获得多少折扣?'
},
answer: {
en: 'Verified students and educators get an extra 10% off any individual plan and an extra 5% off any team plan, up to 25% in total for annual team plans. The team discount stacks with annual pricing, so the more you commit, the more you save.',
'zh-CN':
'经过验证的学生和教育工作者可在任意个人方案上额外享受 10% 折扣,在任意团队方案上额外享受 5% 折扣;年付团队方案最高可累计达 25% 的折扣。团队折扣可与年付价格叠加,因此承诺时间越长,节省越多。'
}
},
{
id: 'how-verification-works',
question: {
en: 'How does verification work?',
'zh-CN': '验证是如何进行的?'
},
answer: {
en: "It takes about a minute, and it's all self-serve:\n\n1. Pick a plan above.\n2. Sign in, or create your Comfy account.\n3. On the payment page, if you're using a recognized school email, your discount is already applied.\n4. If your email isn't recognized, you'll see a quick note to reach support@comfy.org so we can sort it out.",
'zh-CN':
'大约只需一分钟,并且全程自助:\n\n1. 在上方选择一个方案。\n2. 登录或创建您的 Comfy 账户。\n3. 在付款页面,如果您使用的是可识别的学校邮箱,折扣会自动应用。\n4. 如果系统无法识别您的邮箱,您会看到一条提示,请联系 support@comfy.org我们会帮您处理。'
}
},
{
id: 'who-is-eligible',
question: {
en: "Who's eligible?",
'zh-CN': '谁有资格?'
},
answer: {
en: "Enrolled higher-ed students and educators, verified by your school email when you sign up. Teaching a younger class? K-12 and under-18 use needs a quick arrangement with us first, so reach out to us at education@comfy.org and we'll help.",
'zh-CN':
'在读的高等教育学生和教育工作者注册时通过学校邮箱验证。教的是更低年级K-12 及 18 岁以下的使用需要先与我们做一个简单的安排,请通过 education@comfy.org 联系我们,我们会提供帮助。'
}
},
{
id: 'independent-instructor',
question: {
en: "I teach independently or run workshops, and I don't have a school email. Can I still get education pricing?",
'zh-CN': '我独立授课或举办工作坊,没有学校邮箱。我还能获得教育定价吗?'
},
answer: {
en: "The automatic discount keys off recognized school domains, so independent instructors, bootcamps, and for-profit workshops won't clear the email check on their own. Email education@comfy.org with a bit about what you teach and who it's for, and we'll find the right setup for you.",
'zh-CN':
'自动折扣依据可识别的学校域名进行判定,因此独立讲师、训练营和营利性工作坊无法仅凭邮箱验证通过。请发送邮件至 education@comfy.org简单介绍一下您教授的内容和面向的对象我们会为您找到合适的方案。'
}
},
{
id: 'cloud-or-local',
question: {
en: 'Is this for Comfy Cloud or local ComfyUI?',
'zh-CN': '这是针对 Comfy Cloud 还是本地 ComfyUI'
},
answer: {
en: 'The discount is for Comfy Cloud, which gives you managed GPUs and a monthly pool of credits. Local ComfyUI is free and open source for everyone, so you can keep building locally whenever you like.',
'zh-CN':
'折扣适用于 Comfy Cloud它为您提供托管 GPU 和每月的额度池。本地 ComfyUI 对所有人免费且开源,因此您随时可以继续在本地进行创作。'
}
},
{
id: 'students-own-account',
question: {
en: 'Do students each need their own account?',
'zh-CN': '学生需要各自拥有账户吗?'
},
answer: {
en: "You're never charged per seat. On an individual plan, each person has their own subscription and their own credits. On a team plan, you get one workspace with a shared pool of credits and can invite as many students as you want. Bring a class in for a workshop, then remove them when it's over. You only ever pay for the credits, not per student.",
'zh-CN':
'我们从不按席位收费。在个人方案中,每个人都有各自的订阅和各自的额度。在团队方案中,您将获得一个工作区,共享一个额度池,并可邀请任意数量的学生。把一个班级带进来参加工作坊,结束后再将他们移除。您始终只为额度付费,而不是按学生数付费。'
}
},
{
id: 'removing-a-student',
question: {
en: 'What happens to a student when I remove them from the team?',
'zh-CN': '当我把学生从团队中移除后会怎样?'
},
answer: {
en: 'They keep their account. When someone is removed from a team workspace, they return to their own personal workspace on the free plan, with the work they created still theirs. They can upgrade to a paid plan whenever they like. So you can bring a class in for a term and clear them out at the end without anyone losing access or their work.',
'zh-CN':
'他们会保留自己的账户。当某人从团队工作区中被移除后,会回到自己免费方案下的个人工作区,他们创建的作品仍归本人所有。他们可以随时升级到付费方案。因此您可以在一个学期内带一个班级进来,并在学期结束时将他们清出,而不会有人失去访问权限或作品。'
}
},
{
id: 'stack-with-affiliate',
question: {
en: 'Does the education discount stack with the affiliate program?',
'zh-CN': '教育折扣可以与联盟计划叠加吗?'
},
answer: {
en: "Not at the same time. Education pricing is already a program rate, so it doesn't combine with affiliate or referral credits. It does stack with annual commitment pricing on team plans, which is where the true savings come from.",
'zh-CN':
'不能同时使用。教育定价本身已是一种计划优惠价,因此不能与联盟或推荐额度合并使用。但它可以与团队方案的年付承诺价格叠加,这才是真正节省的来源。'
}
},
{
id: 'how-do-i-pay',
question: {
en: 'How do I pay?',
'zh-CN': '我如何付款?'
},
answer: {
en: "Card or ACH at checkout, billed monthly or annually. It's self-serve, so you can start right away. If your school needs to pay by invoice or purchase order, get in touch at education@comfy.org and we can help.",
'zh-CN':
'结账时可使用银行卡或 ACH 付款,按月或按年计费。全程自助,您可以立即开始。如果您的学校需要通过发票或采购订单付款,请联系 education@comfy.org我们会提供帮助。'
}
},
{
id: 'access-start',
question: {
en: 'When does my access start?',
'zh-CN': '我的访问权限何时开始?'
},
answer: {
en: "Right away. Your discount applies the moment you subscribe, so there's no approval queue and nothing to wait for.",
'zh-CN':
'立即开始。折扣会在您订阅的那一刻生效,没有审核排队,也无需等待。'
}
},
{
id: 'semester-end-or-graduate',
question: {
en: 'What happens when the semester ends or I graduate?',
'zh-CN': '学期结束或我毕业后会怎样?'
},
answer: {
en: 'Your account and everything in it stay yours. Education pricing applies as long as your school email keeps qualifying. If that changes, you move to standard pricing, and your workflows, credits, and history all come with you.',
'zh-CN':
'您的账户及其中的一切始终归您所有。只要您的学校邮箱持续符合条件,教育定价就会一直适用。如果条件发生变化,您将转为标准定价,而您的工作流、额度和历史记录都会随之保留。'
}
},
{
id: 'creative-campus',
question: {
en: 'Can my class, program, or school partner with Comfy beyond the discount?',
'zh-CN': '我的班级、项目或学校可以在折扣之外与 Comfy 建立合作吗?'
},
answer: {
en: "Yes, that's what Creative Campus is for. It's our partnership program for educators and institutions who want to go deeper: a dedicated educator Slack channel, teaching resources and workflow libraries, co-marketing and student showcases, a named contact, and early access to new features. Email education@comfy.org and tell us what you're building.",
'zh-CN':
'可以,这正是 Creative Campus 的意义所在。它是我们面向希望深入合作的教育工作者和机构的合作计划:专属的教育者 Slack 频道、教学资源和工作流库、联合营销与学生展示、专属联系人,以及新功能的抢先体验。请发送邮件至 education@comfy.org告诉我们您正在打造什么。'
}
},
{
id: 'share-with-leadership',
question: {
en: 'I need something to share with my leadership or procurement team.',
'zh-CN': '我需要可以分享给领导或采购团队的资料。'
},
answer: {
en: "We can send a one-page summary with pricing, terms, security details, and set up invoice or PO billing if a card won't work. Email education@comfy.org and we'll get you what you need.",
'zh-CN':
'我们可以提供一页式摘要,包含定价、条款和安全详情;如果无法使用银行卡,我们也可以设置发票或采购订单付款。请发送邮件至 education@comfy.org我们会为您准备好所需的资料。'
}
}
] as const

View File

@@ -0,0 +1,43 @@
import type { LocalizedText } from '../i18n/translations'
interface EducationStep {
id: string
title: LocalizedText
description: LocalizedText
}
export const educationSteps: readonly EducationStep[] = [
{
id: 'choose-a-plan',
title: {
en: 'Choose a plan',
'zh-CN': '选择方案'
},
description: {
en: 'Select the right plan for you and sign up with your academic or institutional email',
'zh-CN': '选择适合您的方案,并使用您的学术或院校邮箱注册'
}
},
{
id: 'get-approved',
title: {
en: 'Get approved',
'zh-CN': '获得批准'
},
description: {
en: 'Once you sign in to your Comfy Cloud account and your email is validated, your discount will be applied automatically',
'zh-CN': '当您登录 Comfy Cloud 账户且邮箱通过验证后,折扣将自动应用'
}
},
{
id: 'unlock-your-creativity',
title: {
en: 'Unlock your creativity',
'zh-CN': '释放你的创造力'
},
description: {
en: 'Get started with thousands of pre-built templates and workflows powered by 60,000+ nodes',
'zh-CN': '立即使用由 60,000+ 节点驱动的数千个预置模板和工作流'
}
}
]

View File

@@ -0,0 +1,227 @@
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. On a Team plan, the workspace has a greater concurrency limit per member than the Pro plan. If you run into issues you can request additional support for your team plan limits <a href="https://comfy-org.portal.usepylon.com/forms/team-plan-requests" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'可以。在团队计划中,工作区的每位成员并发上限高于 Pro 计划。如果您遇到问题,可以<a href="https://comfy-org.portal.usepylon.com/forms/team-plan-requests" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">在此处</a>为您的团队计划上限申请额外支持。'
}
},
{
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. If you need to increase your Team plan concurrency limit, seats or API rate limits, contact us <a href="https://comfy-org.portal.usepylon.com/forms/team-plan-requests" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'Standard 和 Creator 上,单个工作流的最长运行时长为 <strong>30 分钟</strong>Pro 上提升至 <strong>1 小时</strong>。超出限制的任务会被自动取消,以保持系统的公平与稳定。您可以同时排队最多 <strong>100 个工作流</strong>,并在 Standard / Creator / Pro 上通过 API 分别并发运行 <strong>1 / 3 / 5</strong> 个工作流。如果您需要提高团队计划的并发上限、席位或 API 速率限制,请<a href="https://comfy-org.portal.usepylon.com/forms/team-plan-requests" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">点击此处</a>联系我们。'
}
},
{
id: 'run-workflows-via-api',
question: {
en: 'Can I run workflows via API?',
'zh-CN': '我可以通过 API 运行工作流吗?'
},
answer: {
en: 'Yes. Run Comfy workflows programmatically via API. Concurrency limits scale with your plan: 1 / 3 / 5 on Standard / Creator / Pro. It\'s built for integrating ComfyUI into your apps, automating batch jobs, or running production pipelines. If you need to request increasing limits you can do so <a href="https://comfy-org.portal.usepylon.com/forms/team-plan-requests" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'可以。通过 API 以编程方式运行 Comfy 工作流。并发上限随您的计划扩展Standard / Creator / Pro 分别为 1 / 3 / 5。它专为将 ComfyUI 集成到您的应用、自动化批处理任务或运行生产管线而打造。如果您需要申请提高上限,可以<a href="https://comfy-org.portal.usepylon.com/forms/team-plan-requests" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">在此处</a>提交。'
}
},
{
id: 'partner-nodes-cost',
question: {
en: 'What are Partner Nodes, and do they cost extra?',
'zh-CN': '什么是合作伙伴节点?它们会额外收费吗?'
},
answer: {
en: 'Partner Nodes let you run proprietary models (like Nano Banana Pro) directly inside your workflow. They draw from the same credit pool as your subscription (no separate bill); how much each call costs depends on the model and parameters you set. These credits work across both Comfy Cloud and Comfy Desktop. <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Read more about Partner Nodes</a>.',
'zh-CN':
'合作伙伴节点让您直接在工作流中运行专有模型(如 Nano Banana Pro。它们从与您订阅相同的积分池中扣费不会单独出账单每次调用的费用取决于模型以及您设置的参数。这些积分在 Comfy Cloud 和 Comfy 桌面版上均可使用。<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">了解更多关于合作伙伴节点的信息</a>。'
}
},
{
id: 'change-cancel-plan-refunds',
question: {
en: 'Can I change or cancel my plan? Do you offer refunds?',
'zh-CN': '我可以更改或取消我的计划吗?你们提供退款吗?'
},
answer: {
en: "You can cancel any time. Cancelling stops all future payments immediately, and your plan stays active until the end of the period you've already paid for. For refunds, submit a support ticket. These are reviewed by our team case by case.",
'zh-CN':
'您可以随时取消。取消后会立即停止所有后续付款,而您的计划在已付费周期结束前仍保持有效。如需退款,请提交支持工单。我们的团队会逐一进行审核。'
}
},
{
id: 'find-invoices-tax-id',
question: {
en: "Where can I find my invoices or add my company's tax ID?",
'zh-CN': '我在哪里可以找到发票或添加公司的税号?'
},
answer: {
en: "You can manage all billing details directly through your Stripe portal. Go to Settings → Plans & Credits → Invoice History to open it. From there, you can view and download invoices, update your billing information, and add your company's tax ID.",
'zh-CN':
'您可以直接通过 Stripe 门户管理所有账单信息。前往 设置 → 计划与积分 → 发票历史 即可打开。在那里,您可以查看和下载发票、更新账单信息,并添加公司的税号。'
}
},
{
id: 'running-comfy-at-scale',
question: {
en: "What if I'm running Comfy at scale?",
'zh-CN': '如果我在大规模运行 Comfy 怎么办?'
},
answer: {
en: 'For teams running Comfy in production and at scale, Enterprise adds higher API rate limits, advanced security, and dedicated support. <a href="https://comfy.org/cloud/enterprise" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Learn more about Enterprise</a> or reach out at <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
'zh-CN':
'对于在生产环境中大规模运行 Comfy 的团队,企业版提供更高的 API 速率限制、高级安全性和专属支持。<a href="https://comfy.org/cloud/enterprise" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">了解更多关于企业版的信息</a>,或通过 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a> 与我们联系。'
}
}
] as const

View File

@@ -0,0 +1,115 @@
import type { TranslationKey } from '../i18n/translations'
import { SHOW_FREE_TIER } from '../config/features'
import { externalLinks } from '../config/routes'
export type BillingCycle = 'monthly' | 'yearly'
export type PlanFeatureStatus = 'included' | 'excluded' | 'coming'
interface PlanFeature {
text: TranslationKey
status?: PlanFeatureStatus
}
export interface PlanFeatureGroup {
titleKey?: TranslationKey
features: PlanFeature[]
}
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', status: 'excluded' },
{ text: 'pricing.feature.longRuntime', status: 'excluded' }
]
},
{
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', status: 'excluded' }
],
isPopular: true
},
{
id: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price',
yearlyPriceKey: 'pricing.plan.pro.yearlyPrice',
yearlyTotalKey: 'pricing.plan.pro.yearlyTotal',
creditsKey: 'pricing.plan.pro.credits',
estimateKey: 'pricing.plan.pro.estimate',
ctaKey: 'pricing.plan.pro.cta',
ctaHref: (cycle) => subscribeUrl('pro', cycle),
features: [
{ text: 'pricing.feature.shortRuntime' },
{ text: 'pricing.feature.addCredits' },
{ text: 'pricing.feature.importModels' },
{ text: 'pricing.feature.longRuntime' }
]
}
]
export const pricingPlans: PricingPlan[] = SHOW_FREE_TIER
? [freePlan, ...standardPricingPlans]
: standardPricingPlans

View File

@@ -0,0 +1,50 @@
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
}
]
export function formatTeamCreditsShort(n: number): string {
const k = n / 1000
return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`
}

View File

@@ -1061,6 +1061,11 @@ const translations = {
en: 'FAQs',
'zh-CN': '常见问题'
},
'cloud.faq.footer': {
en: 'For pricing, plans, credits, and billing details, see the <a href="/cloud/pricing#faq" class="text-primary-comfy-yellow underline">Pricing FAQs</a>.',
'zh-CN':
'有关定价、计划、积分和账单的详细信息,请查看<a href="/zh-CN/cloud/pricing#faq" class="text-primary-comfy-yellow underline">定价常见问题</a>。'
},
'cloud.faq.1.q': {
en: 'What is Comfy Cloud / ComfyUI Cloud?',
'zh-CN': '什么是 Comfy Cloud / ComfyUI Cloud'
@@ -1101,8 +1106,9 @@ const translations = {
'zh-CN': 'Comfy Cloud 使用什么机器或 GPU'
},
'cloud.faq.5.a': {
en: 'Comfy Cloud runs on Blackwell RTX 6000 Pros — 96GB VRAM.',
'zh-CN': 'Comfy Cloud 运行在 Blackwell RTX 6000 Pro 上——96GB 显存。'
en: 'Comfy Cloud runs on Blackwell RTX 6000 Pros — 96GB VRAM, with a library of 900+ pre-installed models and support for many of the most-used custom nodes from the ComfyUI community. We expand node support regularly based on demand and compatibility.',
'zh-CN':
'Comfy Cloud 运行在 Blackwell RTX 6000 Pro 上——96GB 显存,拥有 900+ 预装模型库,并支持 ComfyUI 社区中许多最常用的自定义节点。我们会根据需求和兼容性定期扩展节点支持。'
},
'cloud.faq.6.q': {
en: 'Can I use my existing workflows with Comfy Cloud?',
@@ -1127,18 +1133,18 @@ const translations = {
'zh-CN': '我可以使用自己的模型或检查点吗?'
},
'cloud.faq.8.a': {
en: 'You can always check Cloud to see the list of extensions and models that we support, for free.\nCurrently, we support a wide variety of preinstalled models.\nFor those on the Creator or Pro plans, you can bring in your own fine-tuned LoRAs from CivitAI to perfect your own style.\nImporting from HuggingFace and direct file upload for larger models is on our roadmap.',
en: 'You can always check Cloud to see the list of extensions and models that we support, for free.\nCurrently, we support a wide variety of preinstalled models.\nFor those on the Creator or Pro plans, you can bring in your own fine-tuned LoRAs from CivitAI or HuggingFace to perfect your own style.\nDirect file upload for larger models is on our roadmap.',
'zh-CN':
'您可以随时在 Cloud 上免费查看我们支持的扩展和模型列表。\n目前我们支持大量预装模型。\n对于 Creator 或 Pro 计划用户,您可以导入自己从 CivitAI 微调的 LoRA 来打造专属风格。\n从 HuggingFace 导入和大型模型的直接上传功能已在我们的路线图中。'
'您可以随时在 Cloud 上免费查看我们支持的扩展和模型列表。\n目前我们支持大量预装模型。\n对于 Creator 或 Pro 计划用户,您可以导入自己从 CivitAI 或 HuggingFace 微调的 LoRA 来打造专属风格。\n大型模型的直接文件上传功能已在我们的路线图中。'
},
'cloud.faq.9.q': {
en: 'Can I run long or multiple workflows?',
'zh-CN': '我可以运行长时间或多个工作流吗?'
},
'cloud.faq.9.a': {
en: "Each workflow can run for up to 60 minutes, with one active job at a time. We're adding higher tiers and parallel runs soon for even more flexibility.",
en: 'Each workflow has a max runtime of 30 minutes on Standard and Creator, raised to 1 hour on Pro. Jobs over the limit are cancelled automatically to keep the system fair and stable. You can queue up to 100 workflows at once, and run 1 / 3 / 5 concurrently via API on Standard / Creator / Pro. Need higher API rate limits? Contact enterprise@comfy.org.',
'zh-CN':
'个工作流最长运行 60 分钟,同时运行一个活跃任务。我们即将推出更高层级和并行运行,提供更大灵活性。'
'Standard 和 Creator 上,单个工作流最长运行时长为 30 分钟Pro 上提升至 1 小时。超出限制的任务会被自动取消,以保持系统的公平与稳定。您可以同时排队最多 100 个工作流,并在 Standard / Creator / Pro 上通过 API 分别并发运行 1 / 3 / 5 个工作流。需要更高的 API 速率限制?请联系 enterprise@comfy.org。'
},
'cloud.faq.10.q': {
en: 'How is my user data stored and secured in Comfy Cloud?',
@@ -1150,76 +1156,94 @@ const translations = {
'默认情况下,您的所有输入、输出和工作流都是您账户的私有数据。\n如需增强安全功能或企业级选项请通过 support@comfy.org 联系我们的团队了解更多详情。'
},
'cloud.faq.11.q': {
en: 'How does pricing for Comfy Cloud work?',
'zh-CN': 'Comfy Cloud 的定价是怎样的?'
},
'cloud.faq.11.a': {
en: 'Your monthly plan grants you a single credit balance that you can spend anywhere. Partner Nodes (formerly API nodes) will have set credit prices per usage. For Cloud workflows, you will be charged credits based on the exact duration of your workflow run — longer runs consume more credits.',
'zh-CN':
'您的月度计划会授予一个可在任何地方使用的积分余额。合作伙伴节点(原 API 节点)按使用量设定积分价格。对于云端工作流,将根据工作流运行的确切时长收取积分——运行时间越长消耗的积分越多。'
},
'cloud.faq.12.q': {
en: "What's the difference between Partner Node credits and my Cloud subscription?",
'zh-CN': '合作伙伴节点积分和我的 Cloud 订阅有什么区别?'
},
'cloud.faq.12.a': {
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Comfy Desktop. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
'zh-CN':
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
},
'cloud.faq.13.q': {
en: 'Can I cancel my subscription?',
'zh-CN': '我可以取消订阅吗?'
},
'cloud.faq.13.a': {
en: "Yes. You can cancel your subscription anytime through your account's billing settings, powered by Stripe. Your plan will remain active until the end of your current billing period.",
'zh-CN':
'可以。您可以随时通过账户的账单设置取消订阅(由 Stripe 提供支持)。您的计划将在当前计费周期结束前保持有效。'
},
'cloud.faq.14.q': {
en: "Where can I find my invoices or add my company's tax ID?",
'zh-CN': '我在哪里可以找到发票或添加公司税号?'
},
'cloud.faq.14.a': {
en: "You can manage all billing details directly through your Stripe portal.\nGo to Settings → Plans & Credits → Invoice History to open the Stripe portal. From there, you can view and download invoices, update your billing information, and add your company's tax ID.",
'zh-CN':
'您可以通过 Stripe 门户直接管理所有账单详情。\n前往设置 → 计划与积分 → 发票历史以打开 Stripe 门户。在那里,您可以查看和下载发票、更新账单信息并添加公司税号。'
},
'cloud.faq.15.q': {
en: 'Will ComfyUI always be free to run locally?',
'zh-CN': 'ComfyUI 本地运行会一直免费吗?'
},
'cloud.faq.15.a': {
'cloud.faq.11.a': {
en: "Yes, absolutely. ComfyUI will always be free and open source. You can deploy it however you want, such as downloading it from GitHub, using Docker, custom setups, etc.\n\nComfy Cloud is an optional hosted service for those who prefer convenience, accessibility, or don't have powerful GPUs.",
'zh-CN':
'是的绝对如此。ComfyUI 将始终免费且开源。您可以按任何方式部署它,例如从 GitHub 下载、使用 Docker、自定义设置等。\n\nComfy Cloud 是一项可选的托管服务,适合偏好便捷性、可访问性或没有强大 GPU 的用户。'
},
'cloud.faq.12.q': {
en: 'How much does Comfy Cloud cost?',
'zh-CN': 'Comfy Cloud 的费用是多少?'
},
'cloud.faq.12.a': {
en: 'Plans start at $20/mo with a credit-based model. For full pricing details — credits, plans, Team plan, billing, and refunds — see the <a href="/cloud/pricing#faq" class="text-primary-comfy-yellow underline">Pricing FAQs</a>.',
'zh-CN':
'计划起价为每月 $20采用基于积分的模式。如需完整的定价详情——积分、计划、团队计划、账单和退款——请查看 <a href="/zh-CN/cloud/pricing#faq" class="text-primary-comfy-yellow underline">定价常见问题</a>。'
},
'buildWhat.row1': { en: 'BUILD WHAT', 'zh-CN': '构建' },
'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': '免费开始' },
@@ -1233,113 +1257,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.excluded': {
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': '支持年度承诺、批量定价与定制算力套餐'
@@ -1367,6 +1366,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': '机器配置'
@@ -1380,9 +1383,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',
@@ -1407,9 +1410,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',
@@ -1426,16 +1429,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',
@@ -1455,23 +1458,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: 'FAQs',
'zh-CN': '常见问题'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },
'player.pause': { en: 'Pause', 'zh-CN': '暂停' },
@@ -3940,6 +3939,74 @@ const translations = {
'zh-CN': '阅读联盟计划条款'
},
// Education page (/edu) — head metadata
'education.page.title': {
en: 'Comfy for Education — Student & Educator Discounts',
'zh-CN': 'Comfy 教育版 — 学生与教育工作者优惠'
},
'education.page.description': {
en: 'Up to 25% off Comfy Cloud for every student and educator. Sign up with your academic email for discounted access to cloud-powered ComfyUI workflows.',
'zh-CN':
'所有学生和教育工作者均可享受 Comfy Cloud 最高 25% 的折扣。使用您的学术邮箱注册,即可以优惠价格使用云端 ComfyUI 工作流。'
},
// EducationHeroSection
'education.hero.badge': {
en: 'EDUCATION',
'zh-CN': '教育'
},
'education.hero.title': {
en: 'Up to 25% off*\nfor every student\nand educator',
'zh-CN': '所有学生和教育工作者\n均可享受最高 25% 折扣*'
},
'education.hero.subtitle': {
en: 'Bring Comfy into your classroom — sign up with your academic email for discounted access. Scroll down to see how others use it.',
'zh-CN':
'将 Comfy 带入您的课堂 — 使用您的学术邮箱注册即可享受优惠。向下滚动,了解其他人如何使用它。'
},
'education.hero.footnote': {
en: '*Up to 25% off on annual team plans',
'zh-CN': '*年付团队方案最高可享 25% 折扣'
},
'education.hero.backdropAlt': {
en: 'Floating 3D lab and measuring instruments',
'zh-CN': '漂浮的 3D 实验与测量仪器'
},
// EducationHowItWorksSection
'education.howItWorks.heading': {
en: 'How it works',
'zh-CN': '使用方法'
},
'education.howItWorks.mediaAlt': {
en: 'Comfy-generated artwork of a luminous tree',
'zh-CN': 'Comfy 生成的发光树木艺术作品'
},
// EducationFAQSection
'education.faq.heading': {
en: 'Q&A',
'zh-CN': '问答'
},
// EducationCtaSection
'education.cta.heading': {
en: 'Start creating with ComfyUI',
'zh-CN': '开始使用 ComfyUI 创作'
},
'education.cta.choosePlan': {
en: 'Choose your plan',
'zh-CN': '选择方案'
},
'education.cta.startLearning': {
en: 'Start learning',
'zh-CN': '开始学习'
},
'education.cta.termsLabel': {
en: 'For pricing, plans, credits and billing details, see the Pricing FAQs.',
'zh-CN': '有关定价、方案、额度和账单的详细信息,请参阅定价常见问题。'
},
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {

View File

@@ -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>

View File

@@ -0,0 +1,44 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import PricingSection from '../components/pricing/PricingSection.vue'
import CtaSection from '../templates/education/CtaSection.vue'
import FAQSection from '../templates/education/FAQSection.vue'
import HeroSection from '../templates/education/HeroSection.vue'
import HowItWorksSection from '../templates/education/HowItWorksSection.vue'
import { educationFaqs } from '../data/educationFaq'
import { t } from '../i18n/translations'
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: educationFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
}
---
<BaseLayout
title={t('education.page.title', locale)}
description={t('education.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<PricingSection client:visible locale={locale} />
<HowItWorksSection />
<FAQSection client:visible />
<CtaSection />
</BaseLayout>

View File

@@ -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>

View File

@@ -0,0 +1,44 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PricingSection from '../../components/pricing/PricingSection.vue'
import CtaSection from '../../templates/education/CtaSection.vue'
import FAQSection from '../../templates/education/FAQSection.vue'
import HeroSection from '../../templates/education/HeroSection.vue'
import HowItWorksSection from '../../templates/education/HowItWorksSection.vue'
import { educationFaqs } from '../../data/educationFaq'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: educationFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
}
---
<BaseLayout
title={t('education.page.title', locale)}
description={t('education.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection locale="zh-CN" />
<PricingSection client:visible locale="zh-CN" />
<HowItWorksSection locale="zh-CN" />
<FAQSection locale="zh-CN" client:visible />
<CtaSection locale="zh-CN" />
</BaseLayout>

View File

@@ -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;

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
import { getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
</script>
<template>
<CtaCenter01
:heading="t('education.cta.heading', locale)"
:primary-cta="{
label: t('education.cta.choosePlan', locale),
href: '#plans'
}"
:secondary-cta="{
label: t('education.cta.startLearning', locale),
href: routes.learning
}"
:terms-link="{
label: t('education.cta.termsLabel', locale),
href: routes.cloudPricing
}"
/>
</template>

View File

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

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import HeroBackdrop01 from '../../components/blocks/HeroBackdrop01.vue'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const backdropSrc = 'https://media.comfy.org/website/edu/edu_hero.mp4'
</script>
<template>
<HeroBackdrop01
:backdrop="{
type: 'video',
src: backdropSrc,
alt: t('education.hero.backdropAlt', locale)
}"
:badge-text="t('education.hero.badge', locale)"
:title="t('education.hero.title', locale)"
:subtitle="t('education.hero.subtitle', locale)"
:footnote="t('education.hero.footnote', locale)"
/>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import StepsSplit01 from '../../components/blocks/StepsSplit01.vue'
import { educationSteps } from '../../data/educationSteps'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const steps = educationSteps.map((step) => ({
id: step.id,
title: step.title[locale],
description: step.description[locale]
}))
const mediaSrc = 'https://media.comfy.org/website/edu/how-it-works.png'
</script>
<template>
<StepsSplit01
:heading="t('education.howItWorks.heading', locale)"
:steps="steps"
:media="{
type: 'image',
src: mediaSrc,
alt: t('education.howItWorks.mediaAlt', locale)
}"
/>
</template>

View File

@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// consolidated_billing_enabled routes personal workspaces to the unified
// pricing table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
} satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the
// GraphCanvas onMounted chain before the deep-link loader.

View File

@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { isSubscriptionEnabled } = useSubscription()
// Constants
@@ -260,9 +260,9 @@ async function handleBuy() {
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
// On the consolidated (workspace) billing flow, show the workspace settings
// panel; otherwise show the legacy subscription/credits panel.
const settingsPanel = shouldUseWorkspaceBilling.value
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'

View File

@@ -2,12 +2,11 @@ import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, onMounted, ref } from 'vue'
import { defineComponent, nextTick, onMounted, ref } from 'vue'
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'
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
}
}))
const mockTelemetry = vi.hoisted(() => ({
checkForCompletedTopup: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
useTelemetry: () => mockTelemetry
}))
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 mockBillingRouting = vi.hoisted(() => ({
shouldUseWorkspaceBilling: false
}))
vi.mock('@/composables/billing/useBillingRouting', async () => {
const { ref } = await import('vue')
const shouldUseWorkspaceBilling = ref(false)
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
get: () => shouldUseWorkspaceBilling.value,
set: (value: boolean) => {
shouldUseWorkspaceBilling.value = value
}
})
return {
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
}
})
const mockWorkspaceApi = vi.hoisted(() => ({
getBillingEvents: vi.fn()
@@ -68,7 +77,10 @@ const i18n = createI18n({
additionalInfo: 'Additional Info',
added: 'Added',
accountInitialized: 'Account initialized',
model: 'Model'
model: 'Model',
loadEventsError: 'Failed to load activity. Please try again.',
loadEventsUnknownError:
'Something went wrong while loading activity. Please refresh and try again.'
}
}
}
@@ -95,6 +107,11 @@ const AutoRefreshWrapper = defineComponent({
template: '<UsageLogsTable ref="tableRef" />'
})
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
}
function makeEventsResponse(
events: Partial<AuditLog>[],
overrides: Record<string, unknown> = {}
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
mockFlags.teamWorkspacesEnabled = false
mockBillingRouting.shouldUseWorkspaceBilling = false
mockCustomerEventsService.formatEventType.mockImplementation(
(type: string) => {
switch (type) {
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
})
})
it('shows error message when service throws', async () => {
it('shows a localized fallback instead of a raw Error message', async () => {
mockCustomerEventsService.getMyEvents.mockRejectedValue(
new Error('Network error')
)
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
renderWithAutoRefresh()
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
expect(
screen.getByText(
'Something went wrong while loading activity. Please refresh and try again.'
)
).toBeInTheDocument()
})
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
})
it('shows a localized fallback when the service reports no message', async () => {
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
mockCustomerEventsService.error.value = null
renderWithAutoRefresh()
await waitFor(() => {
expect(
screen.getByText('Failed to load activity. Please try again.')
).toBeInTheDocument()
})
})
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
})
describe('billing events source', () => {
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
mockFlags.teamWorkspacesEnabled = true
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
mockBillingRouting.shouldUseWorkspaceBilling = true
await renderLoaded()
@@ -352,6 +387,90 @@ describe('UsageLogsTable', () => {
})
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
})
it('discards a stale legacy response when routing flips mid-fetch', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
resolveLegacy(
makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
createdAt: '2024-01-01T10:00:00Z'
}
])
)
await flushMicrotasks()
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
})
it('runs top-up completion telemetry for a superseded response', async () => {
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
mockCustomerEventsService.getMyEvents.mockReturnValue(
new Promise((resolve) => {
resolveLegacy = resolve
})
)
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
makeEventsResponse([
{
event_id: 'workspace-1',
event_type: EventType.API_USAGE_COMPLETED,
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
createdAt: '2024-02-01T10:00:00Z'
}
])
)
renderWithAutoRefresh()
mockBillingRouting.shouldUseWorkspaceBilling = true
await waitFor(() => {
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
})
const legacyResponse = makeEventsResponse([
{
event_id: 'legacy-1',
event_type: EventType.CREDIT_ADDED,
params: { amount: 1000 },
createdAt: '2024-01-01T10:00:00Z'
}
])
resolveLegacy(legacyResponse)
await waitFor(() => {
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
legacyResponse.events
)
})
})
})
describe('EventType integration', () => {

View File

@@ -96,11 +96,11 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useTelemetry } from '@/platform/telemetry'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
@@ -109,14 +109,15 @@ import {
useCustomerEventsService
} from '@/services/customerEventsService'
const { t } = useI18n()
const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const customerEventService = useCustomerEventsService()
const { flags } = useFeatureFlags()
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const pagination = ref({
page: 1,
@@ -139,7 +140,12 @@ const tooltipContentMap = computed(() => {
return map
})
// A billing-route flip can overlap two loads against different backends; only
// the latest may mutate state, so a superseded response is discarded.
let latestLoadToken = 0
const loadEvents = async () => {
const loadToken = ++latestLoadToken
loading.value = true
error.value = null
@@ -148,10 +154,17 @@ const loadEvents = async () => {
page: pagination.value.page,
limit: pagination.value.limit
}
const response = useBillingApi.value
const response = shouldUseWorkspaceBilling.value
? await workspaceApi.getBillingEvents(params)
: await customerEventService.getMyEvents(params)
// Completion telemetry must run even when a mid-checkout route flip
// supersedes this load, since legacy and workspace backends emit different
// top-up events and the winning fetch may not carry the completion yet.
useTelemetry()?.checkForCompletedTopup(response?.events)
if (loadToken !== latestLoadToken) return
if (response) {
if (response.events) {
events.value = response.events
@@ -165,24 +178,25 @@ const loadEvents = async () => {
pagination.value.limit = response.limit
}
if (response.total) {
if (response.total != null) {
pagination.value.total = response.total
}
if (response.totalPages) {
if (response.totalPages != null) {
pagination.value.totalPages = response.totalPages
}
// Check if a pending top-up has completed
useTelemetry()?.checkForCompletedTopup(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
const legacyError = shouldUseWorkspaceBilling.value
? null
: customerEventService.error.value
error.value = legacyError || t('credits.loadEventsError')
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
if (loadToken !== latestLoadToken) return
error.value = t('credits.loadEventsUnknownError')
console.error('Error loading events:', err)
} finally {
loading.value = false
if (loadToken === latestLoadToken) loading.value = false
}
}
@@ -198,6 +212,12 @@ const refresh = async () => {
await loadEvents()
}
watch(shouldUseWorkspaceBilling, () => {
refresh().catch((error) => {
console.error('Error loading events:', error)
})
})
defineExpose({
refresh
})

View File

@@ -19,6 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -26,6 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -57,11 +59,23 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
}
})
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
}
}
})
@@ -151,6 +165,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -162,16 +177,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
it('keeps personal on legacy when consolidated billing is disabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team when team workspaces are enabled', () => {
it('selects workspace type for team regardless of consolidated billing', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -272,6 +298,7 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -280,9 +307,27 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
})
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -294,6 +339,20 @@ describe('useBillingContext', () => {
subscriptionPlan: null
})
})
it('never clobbers the list-derived store when a subscription is absent', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
isSubscribed: false,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {

View File

@@ -1,7 +1,6 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
@@ -18,10 +17,10 @@ import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useBillingRouting } from './useBillingRouting'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
@@ -35,8 +34,9 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
* Unified billing context that selects the billing implementation by build/flag.
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for both
* personal (single-seat workspace) and team workspaces
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -69,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const { type } = useBillingRouting()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
@@ -96,16 +96,6 @@ function useBillingContextInternal(): BillingContext {
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use, keyed only on the build/flag:
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
* - Team workspaces feature enabled: workspace (/api/billing), for both
* personal (single-seat workspace) and team workspaces
*/
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
@@ -170,9 +160,12 @@ function useBillingContextInternal(): BillingContext {
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
// Sync subscription info to workspace store for display in workspace switcher.
// Subscribed means active AND not cancelled, so the delete button enables
// after cancellation, even before the period ends. A null subscription means
// "not loaded yet" (adapters are discarded on every workspace/type switch);
// skip it so the transient reinit gap can't clobber the list-derived baseline
// (personal workspaces and subscribed teams already read subscribed there).
watch(
subscription,
(sub) => {
@@ -186,24 +179,27 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
// init detect that it was superseded (its captured adapter is no longer the
// active one), so a stale response can't resolve into a ready state for the
// wrong workspace.
function resetBillingState() {
legacyBillingRef.value = null
workspaceBillingRef.value = null
isInitialized.value = false
isLoading.value = false
error.value = null
}
// type can flip after setup when the team-workspaces flag resolves from
// authenticated config, swapping the active backend; a fresh init is needed.
// The watch fires only when id or type actually changes, so any fire with a
// workspace selected warrants a reinit.
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
if (!newWorkspaceId) {
resetBillingState()
return
}
resetBillingState()
if (!newWorkspaceId) return
isInitialized.value = false
try {
await initialize()
} catch (err) {
@@ -216,17 +212,20 @@ function useBillingContextInternal(): BillingContext {
async function initialize(): Promise<void> {
if (isInitialized.value) return
const adapter = activeContext.value
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
await adapter.initialize()
if (activeContext.value !== adapter) return
isInitialized.value = true
} catch (err) {
if (activeContext.value !== adapter) return
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
if (activeContext.value === adapter) isLoading.value = false
}
}

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags })
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get activeWorkspace() {
return mockActiveWorkspace.value
}
})
}))
const personal = { id: 'w-personal', type: 'personal' as const }
const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
})
it('uses legacy billing when team workspaces are disabled', () => {
mockFlags.teamWorkspacesEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('legacy')
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
expect(type.value).toBe('workspace')
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()
expect(type.value).toBe('legacy')
})
})

View File

@@ -0,0 +1,36 @@
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
// An unloaded workspace has no type yet; stay legacy so bootstrap never
// eagerly routes to workspace billing.
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
return 'legacy'
}
return 'workspace'
})
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
return { type, shouldUseWorkspaceBilling }
}

View File

@@ -6,6 +6,12 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
// Mock the API module
@@ -219,6 +225,86 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
})
})
describe('auth-gated flags on cloud', () => {
beforeEach(() => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
afterEach(() => {
vi.mocked(distributionTypes).isCloud = false
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {}
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
})
})
describe('signupTurnstileMode', () => {

View File

@@ -1,7 +1,9 @@
import { computed, reactive, readonly } from 'vue'
import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -30,6 +32,7 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -46,6 +49,26 @@ function resolveFlag<T>(
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
}
/**
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
* Cloud build it is always false; during the auth window it falls back to the
* cached session value so anonymous bootstrap config cannot route the user to
* the wrong backend before authenticated config confirms the flag.
*/
function resolveAuthGatedFlag(
flagKey: string,
remoteConfigValue: boolean | undefined,
cachedValue: Ref<boolean | undefined>
): boolean {
const override = getDevOverride<boolean>(flagKey)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
}
/**
* Composable for reactive access to server-side feature flags
*/
@@ -104,18 +127,10 @@ export function useFeatureFlags() {
* and prevents race conditions during initialization.
*/
get teamWorkspacesEnabled() {
const override = getDevOverride<boolean>(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
)
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
return resolveAuthGatedFlag(
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
remoteConfig.value.team_workspaces_enabled,
cachedTeamWorkspacesEnabled
)
},
get userSecretsEnabled() {
@@ -175,6 +190,18 @@ export function useFeatureFlags() {
false
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,

View File

@@ -2484,6 +2484,8 @@
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized",
"loadEventsError": "Failed to load activity. Please try again.",
"loadEventsUnknownError": "Something went wrong while loading activity. Please refresh and try again.",
"eventTypes": {
"creditAdded": "Credits Added",
"accountCreated": "Account Created",

View File

@@ -1,85 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsApi } from './useAssetsApi'
const mockAssetsStore = vi.hoisted(() => ({
inputAssets: [] as AssetItem[],
historyAssets: [] as AssetItem[],
inputLoading: false,
historyLoading: false,
inputError: null as string | null,
historyError: null as string | null,
hasMoreHistory: false,
isLoadingMore: false,
updateInputs: vi.fn(),
updateHistory: vi.fn(),
loadMoreHistory: vi.fn()
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => mockAssetsStore
}))
function createAsset(id: string): AssetItem {
return {
id,
name: `${id}.png`,
size: 1,
created_at: '2026-01-01T00:00:00Z',
tags: ['input']
}
}
describe('useAssetsApi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAssetsStore.inputAssets = [createAsset('input-1')]
mockAssetsStore.historyAssets = [createAsset('history-1')]
mockAssetsStore.inputLoading = true
mockAssetsStore.historyLoading = false
mockAssetsStore.inputError = 'input-error'
mockAssetsStore.historyError = 'history-error'
mockAssetsStore.hasMoreHistory = true
mockAssetsStore.isLoadingMore = true
})
it('uses input assets and refreshes inputs', async () => {
const api = useAssetsApi('input')
expect(api.media.value).toEqual([createAsset('input-1')])
expect(api.loading.value).toBe(true)
expect(api.error.value).toBe('input-error')
expect(api.hasMore.value).toBe(false)
expect(api.isLoadingMore.value).toBe(false)
await expect(api.fetchMediaList()).resolves.toEqual([
createAsset('input-1')
])
await expect(api.refresh()).resolves.toEqual([createAsset('input-1')])
await api.loadMore()
expect(mockAssetsStore.updateInputs).toHaveBeenCalledTimes(2)
expect(mockAssetsStore.updateHistory).not.toHaveBeenCalled()
expect(mockAssetsStore.loadMoreHistory).not.toHaveBeenCalled()
})
it('uses output history and loads more history', async () => {
const api = useAssetsApi('output')
expect(api.media.value).toEqual([createAsset('history-1')])
expect(api.loading.value).toBe(false)
expect(api.error.value).toBe('history-error')
expect(api.hasMore.value).toBe(true)
expect(api.isLoadingMore.value).toBe(true)
await expect(api.fetchMediaList()).resolves.toEqual([
createAsset('history-1')
])
await api.loadMore()
expect(mockAssetsStore.updateHistory).toHaveBeenCalledOnce()
expect(mockAssetsStore.updateInputs).not.toHaveBeenCalled()
expect(mockAssetsStore.loadMoreHistory).toHaveBeenCalledOnce()
})
})

View File

@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
import { api } from '@/scripts/api'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
@@ -19,12 +18,6 @@ const mockIsCloud = vi.hoisted(() => ({ value: false }))
// Track the filename passed to createAnnotatedPath
const capturedFilenames = vi.hoisted(() => ({ values: [] as string[] }))
const capturedAnnotatedPaths = vi.hoisted(() => ({
values: [] as Array<{
item: { filename: string; subfolder?: string; type?: string }
options: { rootFolder?: string }
}>
}))
const mockDownloadFile = vi.hoisted(() => vi.fn())
vi.mock('@/base/common/downloadUtil', () => ({
@@ -80,10 +73,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({})
}))
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard
copyToClipboard: vi.fn()
})
}))
@@ -101,50 +93,45 @@ vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
extractWorkflowFromAsset: mockExtractWorkflowFromAsset
}))
const mockAddNodeOnGraph = vi.hoisted(() => vi.fn())
const mockGetCanvasCenter = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
addNodeOnGraph: mockAddNodeOnGraph,
getCanvasCenter: mockGetCanvasCenter
addNodeOnGraph: vi.fn().mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
})
),
getCanvasCenter: vi.fn().mockReturnValue([100, 100])
})
}))
const mockNodeDefsByName = vi.hoisted(() => ({
value: {
LoadImage: {
name: 'LoadImage',
display_name: 'Load Image'
}
} as Record<string, unknown>
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: mockNodeDefsByName.value
nodeDefsByName: {
LoadImage: {
name: 'LoadImage',
display_name: 'Load Image'
}
}
})
}))
vi.mock('@/utils/createAnnotatedPath', () => ({
createAnnotatedPath: vi.fn(
(
item: { filename: string; subfolder?: string; type?: string },
options: { rootFolder?: string }
) => {
capturedAnnotatedPaths.values.push({ item, options })
capturedFilenames.values.push(item.filename)
return item.filename
}
)
createAnnotatedPath: vi.fn((item: { filename: string }) => {
capturedFilenames.values.push(item.filename)
return item.filename
})
}))
const mockDetectNodeTypeFromFilename = vi.hoisted(() => vi.fn())
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: mockDetectNodeTypeFromFilename
detectNodeTypeFromFilename: vi.fn().mockReturnValue({
nodeType: 'LoadImage',
widgetName: 'image'
})
}))
const mockIsResultItemType = vi.hoisted(() => vi.fn())
vi.mock('@/utils/typeGuardUtil', () => ({
isResultItemType: mockIsResultItemType
isResultItemType: vi.fn().mockReturnValue(true)
}))
const mockGetAssetType = vi.hoisted(() => vi.fn())
@@ -199,9 +186,7 @@ vi.mock('@/scripts/api', () => ({
}
}))
const mockAppGraph = vi.hoisted(() => ({
value: { _nodes: [] as unknown[] } as { _nodes: unknown[] } | null
}))
const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
vi.mock('@/scripts/app', () => ({
app: {
get graph() {
@@ -306,43 +291,7 @@ describe('useMediaAssetActions', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
capturedFilenames.values = []
capturedAnnotatedPaths.values = []
mockIsCloud.value = false
mockAppGraph.value = { _nodes: [] }
mockDownloadFile.mockReset()
mockCopyToClipboard.mockReset()
mockShowDialog.mockReset()
mockAddNodeOnGraph.mockReset()
mockAddNodeOnGraph.mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
})
)
mockGetCanvasCenter.mockReset()
mockGetCanvasCenter.mockReturnValue([100, 100])
mockNodeDefsByName.value = {
LoadImage: {
name: 'LoadImage',
display_name: 'Load Image'
}
}
mockDetectNodeTypeFromFilename.mockReset()
mockDetectNodeTypeFromFilename.mockReturnValue({
nodeType: 'LoadImage',
widgetName: 'image'
})
mockIsResultItemType.mockReset()
mockIsResultItemType.mockReturnValue(true)
mockExtractWorkflowFromAsset.mockReset()
mockOpenWorkflowAction.mockReset()
mockExportWorkflowAction.mockReset()
mockCreateAssetExport.mockReset()
mockCreateAssetExport.mockResolvedValue({
task_id: 'test-task-id',
status: 'pending'
})
mockDeleteAsset.mockReset()
mockGetOutputAssetMetadata.mockReset()
mockGetOutputAssetMetadata.mockReturnValue(null)
mockGetAssetType.mockReset()
@@ -350,139 +299,7 @@ describe('useMediaAssetActions', () => {
mockResolveOutputAssetItems.mockResolvedValue([])
})
describe('copyJobId', () => {
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.copyJobId()
expect(mockCopyToClipboard).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
unmount()
})
it('warns when the asset has no job id', async () => {
mockGetAssetType.mockReturnValue('input')
const actions = useMediaAssetActions()
await actions.copyJobId(createMockAsset())
expect(mockCopyToClipboard).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('copies the metadata job id when present', async () => {
mockGetOutputAssetMetadata.mockReturnValue({ jobId: 'job-from-meta' })
const actions = useMediaAssetActions()
await actions.copyJobId(createMockAsset())
expect(mockCopyToClipboard).toHaveBeenCalledWith('job-from-meta')
})
it('copies the output asset id when metadata omits the job id', async () => {
mockGetAssetType.mockReturnValue('output')
const actions = useMediaAssetActions()
await actions.copyJobId(createMockAsset({ id: 'history-id' }))
expect(mockCopyToClipboard).toHaveBeenCalledWith('history-id')
})
})
describe('addWorkflow', () => {
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.addWorkflow()
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
unmount()
})
it('uses the injected media asset when no explicit asset is provided', async () => {
const mediaAsset = createMockMediaAsset({ name: 'context-image.png' })
const { actions, unmount } = mountMediaActions(mediaAsset)
await actions.addWorkflow()
expect(capturedFilenames.values).toContain('context-image.png')
unmount()
})
it('warns when the filename has no compatible loader node', async () => {
mockDetectNodeTypeFromFilename.mockReturnValue({
nodeType: undefined,
widgetName: undefined
})
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset({ name: 'notes.txt' }))
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('reports missing node definitions', async () => {
mockNodeDefsByName.value = {}
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset())
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('reports loader-node creation failure', async () => {
mockAddNodeOnGraph.mockReturnValue(undefined)
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('still adds the node when the expected widget is absent', async () => {
const setDirtyCanvas = vi.fn()
mockAddNodeOnGraph.mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'other', value: '' }],
graph: { setDirtyCanvas }
})
)
mockGetOutputAssetMetadata.mockReturnValue({ subfolder: 'nested' })
mockGetAssetType.mockReturnValue('custom')
mockIsResultItemType.mockReturnValue(false)
const actions = useMediaAssetActions()
await actions.addWorkflow(createMockAsset({ name: 'asset.png' }))
expect(capturedAnnotatedPaths.values.at(-1)).toEqual({
item: {
filename: 'asset.png',
subfolder: 'nested',
type: undefined
},
options: { rootFolder: 'input' }
})
expect(setDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
describe('OSS mode (isCloud = false)', () => {
beforeEach(() => {
mockIsCloud.value = false
@@ -549,83 +366,6 @@ describe('useMediaAssetActions', () => {
})
describe('addMultipleToWorkflow', () => {
it('does nothing for an empty selection', async () => {
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([])
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
})
it('shows a failure toast when none of the selected assets can be added', async () => {
mockDetectNodeTypeFromFilename
.mockReturnValueOnce({ nodeType: undefined, widgetName: undefined })
.mockReturnValueOnce({ nodeType: 'MissingNode', widgetName: 'image' })
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([
createMockAsset({ id: 'a', name: 'unsupported.txt' }),
createMockAsset({ id: 'b', name: 'missing.png' })
])
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('shows a partial warning when only some nodes are added', async () => {
mockAddNodeOnGraph
.mockReturnValueOnce(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'image', value: '', callback: vi.fn() }],
graph: { setDirtyCanvas: vi.fn() }
})
)
.mockReturnValueOnce(undefined)
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([
createMockAsset({ id: 'a', name: 'a.png' }),
createMockAsset({ id: 'b', name: 'b.png' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('adds assets without a matching widget using untyped paths', async () => {
const setDirtyCanvas = vi.fn()
mockAddNodeOnGraph.mockReturnValue(
fromAny<LGraphNode, unknown>({
widgets: [{ name: 'other', value: '' }],
graph: { setDirtyCanvas }
})
)
mockGetAssetType.mockReturnValue('custom')
mockIsResultItemType.mockReturnValue(false)
const actions = useMediaAssetActions()
await actions.addMultipleToWorkflow([
createMockAsset({ id: 'asset-1', name: 'asset-1.png' })
])
expect(capturedAnnotatedPaths.values.at(-1)).toEqual({
item: {
filename: 'asset-1.png',
subfolder: '',
type: undefined
},
options: { rootFolder: undefined }
})
expect(setDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
describe('Cloud mode (isCloud = true)', () => {
beforeEach(() => {
mockIsCloud.value = true
@@ -657,56 +397,10 @@ describe('useMediaAssetActions', () => {
})
})
describe('openWorkflow', () => {
beforeEach(() => {
mockExtractWorkflowFromAsset.mockResolvedValue({
workflow: { version: 0.4 },
filename: 'workflow.json'
})
})
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.openWorkflow()
expect(mockExtractWorkflowFromAsset).not.toHaveBeenCalled()
expect(mockOpenWorkflowAction).not.toHaveBeenCalled()
unmount()
})
it('shows a success toast after opening the workflow', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: true })
const actions = useMediaAssetActions()
await actions.openWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('uses the fallback warning when opening returns no error message', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: false })
const actions = useMediaAssetActions()
await actions.openWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
detail: 'mediaAsset.noWorkflowDataFound'
})
)
})
})
describe('exportWorkflow', () => {
const successResult = { success: true } as const
const cancelledResult = { success: false, cancelled: true } as const
const failureResult = { success: false, error: 'boom' } as const
const failureWithoutError = { success: false } as const
const noWorkflowResult = {
success: false,
error: 'No workflow data available'
@@ -761,31 +455,6 @@ describe('useMediaAssetActions', () => {
)
})
it('does nothing when no asset is available', async () => {
const { actions, unmount } = mountMediaActions()
await actions.exportWorkflow()
expect(mockExtractWorkflowFromAsset).not.toHaveBeenCalled()
expect(mockExportWorkflowAction).not.toHaveBeenCalled()
unmount()
})
it('uses the fallback error when export fails without a message', async () => {
mockExportWorkflowAction.mockResolvedValue(failureWithoutError)
const actions = useMediaAssetActions()
await actions.exportWorkflow(createMockAsset())
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'mediaAsset.failedToExportWorkflow'
})
)
})
it('shows no toast when every asset in a bulk export is cancelled', async () => {
mockExportWorkflowAction.mockResolvedValue(cancelledResult)
const actions = useMediaAssetActions()
@@ -831,118 +500,6 @@ describe('useMediaAssetActions', () => {
})
})
describe('openMultipleWorkflows', () => {
beforeEach(() => {
mockExtractWorkflowFromAsset.mockResolvedValue({
workflow: { version: 0.4 },
filename: 'workflow.json'
})
})
it('does nothing for an empty selection', async () => {
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([])
expect(mockOpenWorkflowAction).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
})
it('shows success when every workflow opens', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: true })
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('shows a missing-workflow warning when none open', async () => {
mockOpenWorkflowAction.mockResolvedValue({ success: false })
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('shows a partial warning when extraction throws for one asset', async () => {
mockExtractWorkflowFromAsset
.mockResolvedValueOnce({
workflow: { version: 0.4 },
filename: 'ok.json'
})
.mockRejectedValueOnce(new Error('missing workflow'))
mockOpenWorkflowAction.mockResolvedValue({ success: true })
const actions = useMediaAssetActions()
await actions.openMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
})
describe('exportMultipleWorkflows', () => {
beforeEach(() => {
mockExtractWorkflowFromAsset.mockResolvedValue({
workflow: { version: 0.4 },
filename: 'workflow.json'
})
})
it('does nothing for an empty selection', async () => {
const actions = useMediaAssetActions()
await actions.exportMultipleWorkflows([])
expect(mockExportWorkflowAction).not.toHaveBeenCalled()
expect(useToast().add).not.toHaveBeenCalled()
})
it('shows no-workflows warning when every export fails', async () => {
mockExportWorkflowAction.mockResolvedValue({
success: false,
error: 'boom'
})
const actions = useMediaAssetActions()
await actions.exportMultipleWorkflows([
createMockAsset({ id: 'a' }),
createMockAsset({ id: 'b' })
])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('counts extraction failures as failed exports', async () => {
mockExtractWorkflowFromAsset.mockRejectedValue(new Error('missing'))
const actions = useMediaAssetActions()
await actions.exportMultipleWorkflows([createMockAsset()])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
})
describe('downloadAssets', () => {
it('downloads the injected media asset when called without explicit assets', () => {
const mediaAsset = createMockMediaAsset({
@@ -977,36 +534,6 @@ describe('useMediaAssetActions', () => {
unmount()
})
it('uses the asset URL when no preview URL is available', () => {
mockGetAssetType.mockReturnValue('input')
const asset = createMockAsset({
name: 'raw image.png',
preview_url: undefined,
user_metadata: { subfolder: 'uploads' }
})
const actions = useMediaAssetActions()
actions.downloadAssets([asset])
expect(mockDownloadFile).toHaveBeenCalledWith(
'http://localhost:8188/api/view?filename=raw+image.png&type=input&subfolder=uploads',
'raw image.png'
)
})
it('shows an error toast when a direct download throws', () => {
mockDownloadFile.mockImplementation(() => {
throw new Error('download failed')
})
const actions = useMediaAssetActions()
actions.downloadAssets([createMockAsset()])
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('keeps single explicit assets on the direct download path in cloud', () => {
mockIsCloud.value = true
mockGetOutputAssetMetadata.mockReturnValue({
@@ -1416,82 +943,6 @@ describe('useMediaAssetActions', () => {
})
expect(payload.naming_strategy).toBe('preserve')
})
it('should include asset ids for imported assets', async () => {
mockGetAssetType.mockImplementation((asset: AssetItem) =>
asset.tags?.includes('output') ? 'output' : 'input'
)
const asset1 = createMockAsset({ id: 'input-1', tags: ['input'] })
const asset2 = createMockAsset({ id: 'input-2', tags: ['input'] })
const actions = useMediaAssetActions()
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toBeUndefined()
expect(payload.asset_ids).toEqual(['input-1', 'input-2'])
expect(payload.naming_strategy).toBe('preserve')
})
it('should mix output job ids and imported asset ids', async () => {
mockGetAssetType.mockImplementation((asset: AssetItem) =>
asset.tags?.includes('output') ? 'output' : 'input'
)
const output = createMockAsset({
id: 'history-id',
name: 'output.png',
tags: ['output']
})
const imported = createMockAsset({ id: 'input-id', tags: ['input'] })
const actions = useMediaAssetActions()
actions.downloadAssets([output, imported])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toEqual(['history-id'])
expect(payload.asset_ids).toEqual(['input-id'])
})
it('should only include a filtered output name once', async () => {
const asset1 = createOutputAsset('a1', 'same.png', 'job1')
const asset2 = createOutputAsset('a2', 'same.png', 'job1')
const actions = useMediaAssetActions()
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_asset_name_filters).toEqual({
job1: ['same.png']
})
})
it('should show an error toast when ZIP export creation fails', async () => {
mockCreateAssetExport.mockRejectedValueOnce(new Error('export failed'))
const asset1 = createOutputAsset('a1', 'img1.png', 'job1')
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadAssets([asset1, asset2])
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
expect(mockTrackExport).not.toHaveBeenCalled()
})
})
describe('downloadAssets - export toast file count', () => {
@@ -1582,200 +1033,6 @@ describe('useMediaAssetActions', () => {
})
})
describe('deleteAssets', () => {
it('returns false for an empty selection', async () => {
const actions = useMediaAssetActions()
const result = await actions.deleteAssets([])
expect(result).toBe(false)
expect(mockShowDialog).not.toHaveBeenCalled()
})
it('returns false when the user cancels', async () => {
mockShowDialog.mockImplementation(
({ props }: { props: { onCancel: () => void } }) => {
props.onCancel()
}
)
const actions = useMediaAssetActions()
const result = await actions.deleteAssets(createMockAsset())
expect(result).toBe(false)
expect(mockDeleteAsset).not.toHaveBeenCalled()
})
it('rejects imported asset deletion outside cloud mode', async () => {
mockIsCloud.value = false
mockGetAssetType.mockReturnValue('input')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(createMockAsset({ tags: ['input'] }))
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
expect(mockDeleteAsset).not.toHaveBeenCalled()
})
it('rejects output deletion when no job id can be resolved', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(
createMockAsset({ id: '', name: 'orphan.png', tags: ['output'] })
)
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
expect(api.deleteItem).not.toHaveBeenCalled()
})
it('updates output history and input listings for mixed successful deletion', async () => {
mockIsCloud.value = true
mockGetAssetType.mockImplementation((asset: AssetItem) =>
asset.tags?.includes('output') ? 'output' : 'input'
)
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets([
createMockAsset({ id: 'history-1', tags: ['output'] }),
createMockAsset({ id: 'input-1', tags: ['input'] })
])
await vi.waitFor(() => {
expect(mockUpdateHistory).toHaveBeenCalled()
})
expect(mockUpdateInputs).toHaveBeenCalled()
expect(api.deleteItem).toHaveBeenCalledWith('history', 'history-1')
expect(mockDeleteAsset).toHaveBeenCalledWith('input-1')
})
it('skips graph cleanup when there is no root graph', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockAppGraph.value = null
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(createMockAsset({ tags: ['input'] }))
await vi.waitFor(() => {
expect(mockDeleteAsset).toHaveBeenCalled()
})
expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
expect(mockClearWidgetValues).not.toHaveBeenCalled()
expect(mockCaptureCanvasState).not.toHaveBeenCalled()
})
it('uses temp widget-value variants when deleting temp assets', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('temp')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(
createMockAsset({
id: 'temp-1',
name: 'preview.png',
hash: 'preview-hash.png',
tags: ['temp']
})
)
await vi.waitFor(() => {
expect(mockClearNodePreviewCache).toHaveBeenCalled()
})
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
expect(valuesArg).toEqual(
new Set(['preview.png [temp]', 'preview-hash.png'])
)
})
it('uses hash-only cleanup values when the asset name is empty', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets(
createMockAsset({
id: 'hash-only',
name: '',
hash: 'only-hash.png',
tags: ['input']
})
)
await vi.waitFor(() => {
expect(mockClearNodePreviewCache).toHaveBeenCalled()
})
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
expect(valuesArg).toEqual(new Set(['only-hash.png']))
})
it('shows a partial warning and cleans up only successfully deleted assets', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('input')
mockDeleteAsset
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('delete failed'))
mockShowDialog.mockImplementation(
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
void props.onConfirm()
}
)
const actions = useMediaAssetActions()
await actions.deleteAssets([
createMockAsset({ id: 'ok', name: 'ok.png', tags: ['input'] }),
createMockAsset({ id: 'bad', name: 'bad.png', tags: ['input'] })
])
await vi.waitFor(() => {
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
expect(valuesArg).toEqual(new Set(['ok.png', 'ok.png [input]']))
})
})
describe('deleteAssets - model cache invalidation', () => {
beforeEach(() => {
mockIsCloud.value = true

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
@@ -15,7 +14,6 @@ vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: vi.fn(),
uploadAssetAsync: vi.fn(),
uploadAssetFromBase64: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
}))
@@ -250,81 +248,6 @@ describe('useUploadModelWizard', () => {
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('does not fetch metadata until the URL matches a supported source', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
expect(wizard.canFetchMetadata.value).toBe(false)
await wizard.fetchMetadata()
expect(assetService.getAssetMetadata).not.toHaveBeenCalled()
expect(wizard.currentStep.value).toBe(1)
})
it('decodes metadata filenames and selects a matching model type tag', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://huggingface.co/org/model',
filename: '%E6%A8%A1%E5%9E%8B.safetensors',
name: '%E5%90%8D%E7%A8%B1',
tags: ['checkpoints'],
preview_image: 'data:image/png;base64,abc'
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = ' https://huggingface.co/org/model '
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(2)
expect(wizard.wizardData.value.url).toBe('https://huggingface.co/org/model')
expect(wizard.wizardData.value.name).toBe('模型.safetensors')
expect(wizard.wizardData.value.previewImage).toBe(
'data:image/png;base64,abc'
)
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('keeps metadata text when percent decoding fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: '%E0%A4%A',
name: '%E0%A4%A',
tags: []
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(2)
expect(wizard.wizardData.value.name).toBe('%E0%A4%A')
expect(wizard.selectedModelType.value).toBeUndefined()
})
it('uses the fallback metadata error for non-error rejections', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockRejectedValue('no metadata')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.currentStep.value).toBe(1)
expect(wizard.uploadError.value).toBe(
'Failed to retrieve metadata. Please check the link and try again.'
)
})
it('uploads with the required model type even if selection changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -356,382 +279,6 @@ describe('useUploadModelWizard', () => {
expect(result?.modelType).toBe('checkpoints')
})
it('clears upload errors and type mismatches when the URL changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.uploadModel()
expect(wizard.uploadTypeMismatch.value).not.toBeNull()
wizard.wizardData.value.url = 'https://civitai.com/models/54321'
await nextTick()
expect(wizard.uploadError.value).toBe('')
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
it('returns null while another upload is in progress', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
type UploadResult = Awaited<
ReturnType<typeof assetService.uploadAssetAsync>
>
let resolveUpload!: (value: UploadResult) => void
vi.mocked(assetService.uploadAssetAsync).mockReturnValue(
new Promise<UploadResult>((resolve) => {
resolveUpload = resolve
})
)
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const firstUpload = wizard.uploadModel()
await nextTick()
await expect(wizard.uploadModel()).resolves.toBeNull()
resolveUpload({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
await expect(firstUpload).resolves.toEqual(
expect.objectContaining({ status: 'success' })
)
})
it('returns null when no model type is selected', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(assetService.uploadAssetAsync).not.toHaveBeenCalled()
})
it('reports an upload error when no valid source is detected', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://example.com/model'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(assetService.uploadAssetAsync).not.toHaveBeenCalled()
})
it('uploads preview images and passes the preview id to the model upload', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetFromBase64).mockResolvedValue(
fromPartial({ id: 'preview-1' })
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'model.safetensors'
}
wizard.wizardData.value.previewImage = 'data:image/jpeg;base64,abc'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(assetService.uploadAssetFromBase64).toHaveBeenCalledWith({
data: 'data:image/jpeg;base64,abc',
name: 'model_preview.jpg',
tags: ['preview']
})
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({ preview_id: 'preview-1' })
)
})
it('continues model upload when preview upload fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetFromBase64).mockRejectedValue(
new Error('preview failed')
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
name: 'model'
}
wizard.wizardData.value.previewImage = 'data:image/webp;base64,abc'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({ preview_id: undefined })
)
expect(wizard.uploadStatus.value).toBe('success')
})
it('treats an already completed async upload as success', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-complete',
status: 'completed',
message: 'Download complete'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.metadata = {
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'queued.safetensors'
}
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'queued.safetensors',
modelType: 'checkpoints',
status: 'success'
})
expect(wizard.uploadStatus.value).toBe('success')
})
it('cleans up an immediately resolved async watcher', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const { useAssetDownloadStore } =
await import('@/stores/assetDownloadStore')
const assetDownloadStore = useAssetDownloadStore()
assetDownloadStore.trackDownload(
'task-ready',
'checkpoints',
'ready.safetensors'
)
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-ready',
asset_id: 'asset-ready',
asset_name: 'ready.safetensors',
bytes_total: 100,
bytes_downloaded: 100,
progress: 100,
status: 'completed' as const
}
})
)
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-ready',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
await nextTick()
expect(wizard.uploadStatus.value).toBe('success')
})
it('uses the default failed-download message when no error is available', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-fallback-fail',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-fallback-fail',
asset_id: '',
asset_name: '',
bytes_total: 1000,
bytes_downloaded: 500,
progress: 50,
status: 'failed' as const
}
})
)
await nextTick()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('assetBrowser.downloadFailed')
})
it('uses fallback labels for unknown mismatch types', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-unknown',
name: 'model.safetensors',
tags: ['models']
}
})
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'unknown-required'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadTypeMismatch.value).toEqual({
importedModelType: undefined,
importedModelTypeLabel: undefined,
requiredModelType: 'unknown-required',
requiredModelTypeLabel: 'unknown-required'
})
})
it('uses a generic upload error for non-error upload failures', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockRejectedValue('failed')
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('Failed to upload model')
})
it('navigates backward only after the first step', () => {
const wizard = setupUploadModelWizard(modelTypes)
wizard.goToPreviousStep()
expect(wizard.currentStep.value).toBe(1)
wizard.currentStep.value = 3
wizard.goToPreviousStep()
expect(wizard.currentStep.value).toBe(2)
})
it('resets wizard state and cancels pending async status watching', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'async',
task: {
task_id: 'task-reset',
status: 'created',
message: 'Download queued'
}
})
const wizard = setupUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.wizardData.value.name = 'Model'
wizard.wizardData.value.tags = ['checkpoints']
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
wizard.resetWizard()
expect(wizard.currentStep.value).toBe(1)
expect(wizard.uploadStatus.value).toBeUndefined()
expect(wizard.uploadError.value).toBe('')
expect(wizard.wizardData.value).toEqual({
url: '',
name: '',
tags: []
})
expect(wizard.selectedModelType.value).toBeUndefined()
})
it('returns the synced asset filename for sync imports', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')

View File

@@ -12,7 +12,6 @@ import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
const mockGetCategoryForNodeType = vi.hoisted(() => vi.fn())
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -34,7 +33,7 @@ vi.mock('@/stores/modelToNodeStore', () => {
return {
useModelToNodeStore: vi.fn(() => ({
getRegisteredNodeTypes: () => registeredNodeTypes,
getCategoryForNodeType: mockGetCategoryForNodeType
getCategoryForNodeType: vi.fn()
}))
}
})
@@ -173,28 +172,6 @@ describe(assetService.getAssetMetadata, () => {
).rejects.toThrow('File too large')
})
it('falls back to the unknown localized message for unrecognized error codes', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ code: 'NOT_A_REAL_CODE' }, { ok: false, status: 400 })
)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('falls back to unknown when error JSON cannot be parsed', async () => {
fetchApiMock.mockResolvedValueOnce({
ok: false,
status: 400,
json: vi.fn().mockRejectedValue(new Error('bad json'))
} as unknown as Response)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('throws a localized message when validation reports is_valid=false', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
@@ -212,20 +189,6 @@ describe(assetService.getAssetMetadata, () => {
).rejects.toThrow('Unsafe virus scan')
})
it('falls back to unknown when validation errors are absent', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
content_length: 100,
final_url: 'https://example.com/model.safetensors',
validation: { is_valid: false }
})
)
await expect(
assetService.getAssetMetadata('https://example.com/model.safetensors')
).rejects.toThrow('Unknown error')
})
it('encodes the URL in the query string', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
@@ -245,115 +208,12 @@ describe(assetService.getAssetMetadata, () => {
})
})
describe(assetService.getAssetsForNodeType, () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCategoryForNodeType.mockReset()
})
it('returns an empty list for invalid node types without fetching', async () => {
await expect(assetService.getAssetsForNodeType('')).resolves.toEqual([])
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('returns an empty list when the node type has no asset category', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
await expect(
assetService.getAssetsForNodeType('UnknownNode')
).resolves.toEqual([])
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('fetches category assets with default pagination', async () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const assets = [
validAsset({ id: 'ckpt-1', tags: ['models', 'checkpoints'] })
]
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
await expect(
assetService.getAssetsForNodeType('CheckpointLoaderSimple')
).resolves.toEqual(assets)
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models,checkpoints')
expect(params.get('limit')).toBe('500')
expect(params.has('offset')).toBe(false)
})
it('passes positive offsets for category asset pagination', async () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await assetService.getAssetsForNodeType('LoraLoader', {
limit: 25,
offset: 50
})
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models,loras')
expect(params.get('limit')).toBe('25')
expect(params.get('offset')).toBe('50')
})
})
describe(assetService.getAssetDetails, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws when the details response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({}, { ok: false, status: 404 })
)
await expect(assetService.getAssetDetails('missing')).rejects.toThrow(
'Unable to load asset details for missing: Server returned 404'
)
})
it('throws when the details response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ id: 'asset-1' }))
await expect(assetService.getAssetDetails('asset-1')).rejects.toThrow(
/Invalid asset response/
)
})
it('returns validated asset details', async () => {
const asset = validAsset({ id: 'asset-details' })
fetchApiMock.mockResolvedValueOnce(buildResponse(asset))
await expect(
assetService.getAssetDetails('asset-details')
).resolves.toEqual(asset)
})
})
describe(assetService.uploadAssetFromUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('throws when URL upload returns a non-ok response', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 500 })
)
await expect(
assetService.uploadAssetFromUrl({
url: 'https://example.com/input.png',
name: 'input.png'
})
).rejects.toThrow('Failed to upload asset')
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -434,61 +294,6 @@ describe(assetService.uploadAssetFromBase64, () => {
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('throws when base64 upload returns a non-ok response', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
try {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 507 })
)
await expect(
assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt'
})
).rejects.toThrow('Failed to upload asset from base64: 507')
} finally {
fetchSpy.mockRestore()
}
})
it('posts base64 uploads with tags and user metadata', async () => {
const uploadedAsset = {
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
created_new: false
}
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
try {
fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset))
const result = await assetService.uploadAssetFromBase64({
data: 'data:text/plain;base64,aGVsbG8=',
name: 'input.txt',
tags: ['input', 'mask'],
user_metadata: { source: 'paste' }
})
expect(result).toEqual(uploadedAsset)
const request = fetchApiMock.mock.calls[0]?.[1]
expect(request).toEqual(expect.objectContaining({ method: 'POST' }))
expect(request?.body).toBeInstanceOf(FormData)
const formData = request?.body
if (!(formData instanceof FormData)) {
throw new Error('Expected base64 upload body to be FormData')
}
expect(formData.get('tags')).toBe(JSON.stringify(['input', 'mask']))
expect(formData.get('user_metadata')).toBe(
JSON.stringify({ source: 'paste' })
)
} finally {
fetchSpy.mockRestore()
}
})
it('does not invalidate cached input assets when the upload response is invalid', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -550,7 +355,6 @@ describe(assetService.uploadAssetFromBase64, () => {
describe(assetService.uploadAssetAsync, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('returns an async result when the server responds 202', async () => {
@@ -585,64 +389,6 @@ describe(assetService.uploadAssetAsync, () => {
asset: expect.objectContaining({ id: 'asset-2' })
})
})
it('throws when the async upload response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 502 })
)
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to upload asset')
})
it('throws when an async upload task response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ task_id: 'task-1', status: 'waiting' }, { status: 202 })
)
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to parse async upload response')
})
it('throws when a sync upload asset response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ id: 'asset-2' }))
await expect(
assetService.uploadAssetAsync({
source_url: 'https://example.com/model.safetensors'
})
).rejects.toThrow('Failed to parse sync upload response')
})
it('invalidates cached input assets for completed async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'completed' },
{ ok: true, status: 202 }
)
)
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
source_url: 'https://example.com/input.png',
tags: ['input']
})
const refreshed = await assetService.getInputAssetsIncludingPublic()
expect(refreshed).toEqual(freshAssets)
expect(fetchApiMock).toHaveBeenCalledTimes(3)
})
})
describe(assetService.deleteAsset, () => {
@@ -670,94 +416,6 @@ describe(assetService.deleteAsset, () => {
})
})
describe(assetService.addAssetTags, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('posts tags and returns the parsed tag operation result', async () => {
const result = { total_tags: ['input', 'mask'], added: ['mask'] }
fetchApiMock.mockResolvedValueOnce(buildResponse(result))
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).resolves.toEqual(result)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/asset-1/tags',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ tags: ['mask'] })
})
)
})
it('throws when adding tags fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 403 })
)
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).rejects.toThrow(
'Unable to add tags to asset asset-1: Server returned 403'
)
})
it('throws when the add-tags response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ added: ['mask'] }))
await expect(
assetService.addAssetTags('asset-1', ['mask'])
).rejects.toThrow()
})
})
describe(assetService.removeAssetTags, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateInputAssetsIncludingPublic()
})
it('deletes tags and returns the parsed tag operation result', async () => {
const result = { total_tags: ['input'], removed: ['mask'] }
fetchApiMock.mockResolvedValueOnce(buildResponse(result))
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).resolves.toEqual(result)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/asset-1/tags',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ tags: ['mask'] })
})
)
})
it('throws when removing tags fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).rejects.toThrow(
'Unable to remove tags from asset asset-1: Server returned 404'
)
})
it('throws when the remove-tags response is invalid', async () => {
fetchApiMock.mockResolvedValueOnce(buildResponse({ removed: ['mask'] }))
await expect(
assetService.removeAssetTags('asset-1', ['mask'])
).rejects.toThrow()
})
})
describe(assetService.getAssetModelFolders, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -823,16 +481,6 @@ describe(assetService.updateAsset, () => {
})
)
})
it('throws when the update response is not ok', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 409 })
)
await expect(
assetService.updateAsset('asset-1', { name: 'renamed.safetensors' })
).rejects.toThrow('Unable to update asset asset-1: Server returned 409')
})
})
describe(assetService.getAssetsByTag, () => {
@@ -867,21 +515,6 @@ describe(assetService.getAssetsByTag, () => {
expect(params.get('include_tags')).toBe('input')
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
it('forwards explicit public filtering and offset pagination', async () => {
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await assetService.getAssetsByTag('input', false, {
limit: 30,
offset: 60
})
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_public')).toBe('false')
expect(params.get('limit')).toBe('30')
expect(params.get('offset')).toBe('60')
})
})
describe(assetService.getAllAssetsByTag, () => {
@@ -929,31 +562,6 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.has('offset')).toBe(false)
})
it('uses the default page size when limit is not positive', async () => {
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse([]))
await expect(
assetService.getAllAssetsByTag('input', true, { limit: 0 })
).resolves.toEqual([])
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('limit')).toBe('500')
})
it('throws before fetching when the pagination signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
assetService.getAllAssetsByTag('input', true, {
signal: controller.signal
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
@@ -1095,75 +703,6 @@ describe(assetService.getAllAssetsByTag, () => {
})
})
describe(assetService.createAssetExport, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('posts export options and returns the export task', async () => {
const task = { task_id: 'export-1', status: 'created', message: 'queued' }
fetchApiMock.mockResolvedValueOnce(buildResponse(task))
await expect(
assetService.createAssetExport({
asset_ids: ['asset-1'],
include_previews: true
})
).resolves.toEqual(task)
expect(fetchApiMock).toHaveBeenCalledWith(
'/assets/export',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
asset_ids: ['asset-1'],
include_previews: true
})
})
)
})
it('throws when creating an export fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 503 })
)
await expect(
assetService.createAssetExport({ asset_ids: ['asset-1'] })
).rejects.toThrow('Failed to create asset export: 503')
})
})
describe(assetService.getExportDownloadUrl, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the export download URL', async () => {
const download = {
url: 'https://example.com/export.zip',
expires_at: '2026-07-01T00:00:00Z'
}
fetchApiMock.mockResolvedValueOnce(buildResponse(download))
await expect(
assetService.getExportDownloadUrl('export.zip')
).resolves.toEqual(download)
expect(fetchApiMock).toHaveBeenCalledWith('/assets/exports/export.zip')
})
it('throws when export download URL lookup fails', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)
await expect(
assetService.getExportDownloadUrl('missing.zip')
).rejects.toThrow('Failed to get export download URL: 404')
})
})
describe(assetService.getInputAssetsIncludingPublic, () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1190,17 +729,6 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
expect(params.get('limit')).toBe('500')
})
it('throws before starting a shared request when the caller signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
assetService.getInputAssetsIncludingPublic(controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchApiMock).not.toHaveBeenCalled()
})
it('fetches fresh input assets after explicit invalidation', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetType } from '@/platform/assets/utils/assetTypeUtil'
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'image.png',
preview_url: '',
tags: [],
created_at: '',
updated_at: '',
size: 0,
mime_type: 'image/png',
user_metadata: {},
...overrides
} as AssetItem
}
describe('getAssetType', () => {
it('prefers the preview URL type over tags', () => {
expect(
getAssetType(
asset({
preview_url: '/api/view?filename=image.png&type=temp',
tags: ['output']
})
)
).toBe('temp')
})
it('falls back to tags and then the supplied default type', () => {
expect(getAssetType(asset({ tags: ['input'] }))).toBe('input')
expect(getAssetType(asset(), 'input')).toBe('input')
})
})

View File

@@ -1,62 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
const { apiURL } = vi.hoisted(() => ({
apiURL: vi.fn((path: string) => `https://comfy.local${path}`)
}))
vi.mock('@/scripts/api', () => ({
api: { apiURL }
}))
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'folder image.png',
preview_url: '',
tags: ['output'],
created_at: '',
updated_at: '',
size: 0,
mime_type: 'image/png',
user_metadata: {},
...overrides
} as AssetItem
}
beforeEach(() => {
apiURL.mockClear()
})
describe('getAssetUrl', () => {
it('builds encoded view URLs with type and subfolder', () => {
const url = getAssetUrl(
asset({
user_metadata: { subfolder: 'nested/path' }
})
)
expect(apiURL).toHaveBeenCalledWith(
'/view?filename=folder+image.png&type=output&subfolder=nested%2Fpath'
)
expect(url).toBe(
'https://comfy.local/view?filename=folder+image.png&type=output&subfolder=nested%2Fpath'
)
})
it('uses preview URL type and omits empty subfolders', () => {
getAssetUrl(
asset({
preview_url: '/api/view?filename=image.png&type=temp',
tags: ['output'],
user_metadata: { subfolder: '' }
})
)
expect(apiURL).toHaveBeenCalledWith(
'/view?filename=folder+image.png&type=temp'
)
})
})

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -29,8 +28,6 @@ interface HostAssetWidget extends IBaseWidget<
node: LGraphNode
}
type AssetWidget = IBaseWidget<string | undefined, 'asset', IWidgetAssetOptions>
type OnWidgetChanged = NonNullable<LGraphNode['onWidgetChanged']>
function checkpointAsset(name: string): AssetItem {
@@ -169,118 +166,4 @@ describe('createAssetWidget', () => {
)
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('falls back to widget name and empty current value for cloned widgets', async () => {
const { node } = createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'lora_name',
nodeTypeForBrowser: 'LoraLoader'
})
assertAssetOptions(sourceWidget.options)
const clonedWidget: AssetWidget = {
type: 'asset',
name: 'lora_name',
value: undefined,
options: sourceWidget.options,
y: 0
}
await sourceWidget.options.openModal(clonedWidget)
expect(firstShowOptions()).toMatchObject({
nodeType: 'LoraLoader',
inputName: 'lora_name',
currentValue: ''
})
})
it('rejects malformed asset selections', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(
fromPartial({ id: 'asset-without-name' })
)
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('rejects invalid asset filenames', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(checkpointAsset('../bad.safetensors'))
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('updates ownerless cloned widgets without node callbacks', async () => {
const { node, onWidgetChanged } = createAssetWidgetNode()
const sourceWidget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(sourceWidget.options)
const callback = vi.fn<NonNullable<IBaseWidget['callback']>>()
const clonedWidget: AssetWidget = {
type: 'asset',
name: 'ckpt_name',
value: 'fake_model.safetensors',
callback,
options: sourceWidget.options,
y: 0
}
await sourceWidget.options.openModal(clonedWidget)
firstShowOptions().onAssetSelected?.(
checkpointAsset('real_model.safetensors')
)
expect(clonedWidget.value).toBe('real_model.safetensors')
expect(callback).toHaveBeenCalledWith('real_model.safetensors')
expect(onWidgetChanged).not.toHaveBeenCalled()
expect(captureCanvasState).toHaveBeenCalledOnce()
})
it('does not capture canvas state when the selection is unchanged', async () => {
const { node } = createAssetWidgetNode()
const widget = createAssetWidget({
node,
widgetName: 'ckpt_name',
nodeTypeForBrowser: 'CheckpointLoaderSimple',
defaultValue: 'fake_model.safetensors'
})
assertAssetOptions(widget.options)
await widget.options.openModal(widget)
firstShowOptions().onAssetSelected?.(
checkpointAsset('fake_model.safetensors')
)
expect(widget.value).toBe('fake_model.safetensors')
expect(captureCanvasState).not.toHaveBeenCalled()
})
})

View File

@@ -18,7 +18,7 @@
</div>
<!-- Workspace mode: workspace-aware subscription content (renders its own footer) -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<SubscriptionPanelContentWorkspace v-if="shouldUseWorkspaceBilling" />
<!-- Legacy mode: user-level subscription content -->
<template v-else>
<SubscriptionPanelContentLegacy />
@@ -29,24 +29,20 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { shouldUseWorkspaceBilling } = useBillingRouting()
const { isActiveSubscription } = useBillingContext()
</script>

View File

@@ -9,7 +9,7 @@ const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
@@ -35,12 +35,10 @@ vi.mock('@/services/dialogService', () => ({
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
vi.mock('@/composables/billing/useBillingRouting', () => ({
useBillingRouting: () => ({
get shouldUseWorkspaceBilling() {
return mockShouldUseWorkspaceBilling
}
})
}))
@@ -88,7 +86,7 @@ describe('useSubscriptionDialog', () => {
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockShouldUseWorkspaceBilling.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -119,7 +117,7 @@ describe('useSubscriptionDialog', () => {
})
it('does not wire onChooseTeam on the unified table (personal subscribes directly)', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -131,7 +129,7 @@ describe('useSubscriptionDialog', () => {
})
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -146,7 +144,7 @@ describe('useSubscriptionDialog', () => {
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -157,7 +155,7 @@ describe('useSubscriptionDialog', () => {
})
it('opens the team tab when planMode is forced from a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -167,8 +165,9 @@ describe('useSubscriptionDialog', () => {
expect(props.initialPlanMode).toBe('team')
})
it('uses the legacy table (with onChooseTeam) when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
it('uses the legacy table (with onChooseTeam) on the legacy billing flow', () => {
mockShouldUseWorkspaceBilling.value = false
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
@@ -178,7 +177,7 @@ describe('useSubscriptionDialog', () => {
})
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = true
const { showPricingTable } = useSubscriptionDialog()
@@ -196,7 +195,7 @@ describe('useSubscriptionDialog', () => {
})
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = false
const { showPricingTable } = useSubscriptionDialog()
@@ -220,7 +219,7 @@ describe('useSubscriptionDialog', () => {
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
@@ -232,7 +231,7 @@ describe('useSubscriptionDialog', () => {
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()

View File

@@ -2,7 +2,7 @@ import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
@@ -24,7 +24,7 @@ export interface SubscriptionDialogOptions {
}
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const { shouldUseWorkspaceBilling } = useBillingRouting()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
@@ -57,7 +57,7 @@ export const useSubscriptionDialog = () => {
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
if (
flags.teamWorkspacesEnabled &&
shouldUseWorkspaceBilling.value &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
@@ -95,9 +95,10 @@ export const useSubscriptionDialog = () => {
}
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) when team workspaces are enabled. Replaces the old
// personal-vs-team workspace fork. Flag-off keeps the legacy table.
if (flags.teamWorkspacesEnabled) {
// one workspace) for workspaces on the consolidated billing flow. Replaces
// the old personal-vs-team workspace fork. Personal workspaces still on the
// legacy flow (consolidated billing disabled) get the legacy table.
if (shouldUseWorkspaceBilling.value) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.
// Resolved lazily (not at composable setup): these three composables form

View File

@@ -1,4 +1,5 @@
import {
cachedConsolidatedBillingEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -55,10 +56,14 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = config
remoteConfig.value = config
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
if (useAuth)
if (useAuth) {
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
cachedConsolidatedBillingEnabled.value = Boolean(
config.consolidated_billing_enabled
)
}
return
}

View File

@@ -59,3 +59,8 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
undefined
)
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
undefined
)

View File

@@ -111,6 +111,7 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -11,21 +11,49 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const env = vi.hoisted(() => {
const state = {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
return state[key]
}
})
return { state, fakeRef }
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
useCurrentUser: () => ({ isLoggedIn: env.fakeRef('isLoggedIn') })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
useBillingContext: () => ({
isActiveSubscription: env.fakeRef('isActiveSubscription'),
type: env.fakeRef('billingType')
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
flags: {
get teamWorkspacesEnabled() {
return env.state.teamWorkspacesEnabled
},
get userSecretsEnabled() {
return env.state.userSecretsEnabled
}
}
})
}))
@@ -34,8 +62,12 @@ vi.mock('@/composables/useVueFeatureFlags', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
get isCloud() {
return env.state.isCloud
},
get isDesktop() {
return env.state.isDesktop
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -77,6 +109,16 @@ describe('useSettingUI', () => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
Object.assign(env.state, {
isCloud: false,
isDesktop: false,
isLoggedIn: false,
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
})
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
@@ -137,4 +179,59 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
describe('legacy billing in the workspace layout', () => {
const navKeys = (groups: { items: { id: string }[] }[]) =>
groups.flatMap((group) => group.items.map((item) => item.id))
beforeEach(() => {
Object.assign(env.state, {
isCloud: true,
isLoggedIn: true,
teamWorkspacesEnabled: true,
isActiveSubscription: true
})
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
})
it('exposes the legacy plan panel when billing is legacy', () => {
env.state.billingType = 'legacy'
const { defaultCategory, navGroups } = useSettingUI('subscription')
expect(defaultCategory.value.key).toBe('subscription')
expect(navKeys(navGroups.value)).toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('hides the legacy plan panel when billing is workspace', () => {
env.state.billingType = 'workspace'
const { navGroups } = useSettingUI()
expect(navKeys(navGroups.value)).not.toContain('subscription')
expect(navKeys(navGroups.value)).toContain('workspace')
})
it('never renders the plan panel in more than one tab', () => {
const countSubscription = () => {
const { navGroups } = useSettingUI()
return navKeys(navGroups.value).filter((id) => id === 'subscription')
.length
}
for (const teamWorkspacesEnabled of [true, false]) {
for (const billingType of ['legacy', 'workspace'] as const) {
for (const isLoggedIn of [true, false]) {
Object.assign(env.state, {
teamWorkspacesEnabled,
billingType,
isLoggedIn
})
expect(countSubscription()).toBeLessThanOrEqual(1)
}
}
}
})
})
})

View File

@@ -53,7 +53,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useBillingContext()
const { isActiveSubscription, type: billingType } = useBillingContext()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -157,6 +157,13 @@ export function useSettingUI(
return isActiveSubscription.value
})
const shouldShowLegacyPlanCreditsPanel = computed(
() =>
isLoggedIn.value &&
billingType.value === 'legacy' &&
shouldShowPlanCreditsPanel.value
)
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -301,6 +308,9 @@ export function useSettingUI(
label: 'General',
children: [
translateCategory(userPanel.node),
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [translateCategory(subscriptionPanel.node)]
: []),
...coreSettingCategories.value.slice(0, 1).map(translateCategory),
...(shouldShowSecretsPanel.value
? [translateCategory(secretsPanel.node)]
@@ -332,9 +342,7 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
...(isLoggedIn.value &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel.node]
: []),
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : []),

View File

@@ -1,286 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface AxiosLikeError extends Error {
isAxiosError: true
response?: {
status: number
data?: {
message?: string
}
}
}
const mockClient = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn()
}))
const mockAxios = vi.hoisted(() => ({
create: vi.fn(() => mockClient),
isAxiosError: vi.fn(
(error: unknown): error is AxiosLikeError =>
typeof error === 'object' &&
error !== null &&
'isAxiosError' in error &&
error.isAxiosError === true
)
}))
vi.mock('axios', () => ({
default: mockAxios
}))
import { useComfyRegistryService } from './comfyRegistryService'
function response<T>(data: T) {
return { data }
}
function axiosError(
message: string,
responseData?: AxiosLikeError['response']
): AxiosLikeError {
const error = new Error(message) as AxiosLikeError
error.isAxiosError = true
if (responseData) error.response = responseData
return error
}
describe('useComfyRegistryService', () => {
beforeEach(() => {
mockClient.get.mockReset()
mockClient.post.mockReset()
mockAxios.isAxiosError.mockClear()
})
it('configures the registry axios client with repeated query params', () => {
expect(mockAxios.create).toHaveBeenCalledWith({
baseURL: 'https://api.comfy.org',
headers: {
'Content-Type': 'application/json'
},
paramsSerializer: {
indexes: null
}
})
})
it('returns response data and clears loading state for successful requests', async () => {
mockClient.get.mockResolvedValueOnce(response({ nodes: [] }))
const service = useComfyRegistryService()
const result = await service.search({ search: 'manager' })
expect(result).toEqual({ nodes: [] })
expect(mockClient.get).toHaveBeenCalledWith('/nodes/search', {
params: { search: 'manager' },
signal: undefined
})
expect(service.error.value).toBeNull()
expect(service.isLoading.value).toBe(false)
})
it('skips node definition requests when pack id or version is missing', async () => {
const service = useComfyRegistryService()
await expect(
service.getNodeDefs({ packId: '', version: '1.0.0' })
).resolves.toBeNull()
await expect(
service.getNodeDefs({ packId: 'pack', version: '' })
).resolves.toBeNull()
expect(mockClient.get).not.toHaveBeenCalled()
})
it('passes query params and abort signals through node definition requests', async () => {
const signal = new AbortController().signal
mockClient.get.mockResolvedValueOnce(response([{ name: 'KSampler' }]))
const service = useComfyRegistryService()
const result = await service.getNodeDefs(
{ packId: 'pack', version: '1.0.0', page: 2 },
signal
)
expect(result).toEqual([{ name: 'KSampler' }])
expect(mockClient.get).toHaveBeenCalledWith(
'/nodes/pack/versions/1.0.0/comfy-nodes',
{
params: { page: 2 },
signal
}
)
})
it('routes publisher, pack, and review methods to their registry endpoints', async () => {
mockClient.get
.mockResolvedValueOnce(response({ id: 'publisher' }))
.mockResolvedValueOnce(response([{ id: 'pack' }]))
.mockResolvedValueOnce(response([{ version: '1.0.0' }]))
.mockResolvedValueOnce(response({ id: 'version' }))
.mockResolvedValueOnce(response({ id: 'pack' }))
.mockResolvedValueOnce(response({ id: 'pack' }))
.mockResolvedValueOnce(response({ id: 'pack' }))
mockClient.post
.mockResolvedValueOnce(response({ id: 'reviewed' }))
.mockResolvedValueOnce(response({ node_versions: [] }))
const service = useComfyRegistryService()
const signal = new AbortController().signal
await expect(
service.getPublisherById('publisher', signal)
).resolves.toEqual({ id: 'publisher' })
await expect(
service.listPacksForPublisher('publisher', true, signal)
).resolves.toEqual([{ id: 'pack' }])
await expect(
service.getPackVersions(
'pack',
{ statuses: ['NodeVersionStatusActive'] },
signal
)
).resolves.toEqual([{ version: '1.0.0' }])
await expect(
service.getPackByVersion('pack', 'version', signal)
).resolves.toEqual({ id: 'version' })
await expect(service.getPackById('pack', signal)).resolves.toEqual({
id: 'pack'
})
await expect(
service.inferPackFromNodeName('KSampler', signal)
).resolves.toEqual({ id: 'pack' })
await expect(service.listAllPacks({ page: 1 }, signal)).resolves.toEqual({
id: 'pack'
})
await expect(service.postPackReview('pack', 5, signal)).resolves.toEqual({
id: 'reviewed'
})
await expect(
service.getBulkNodeVersions(
[{ node_id: 'pack', version: '1.0.0' }],
signal
)
).resolves.toEqual({ node_versions: [] })
expect(mockClient.get).toHaveBeenNthCalledWith(1, '/publishers/publisher', {
signal
})
expect(mockClient.get).toHaveBeenNthCalledWith(
2,
'/publishers/publisher/nodes',
{
params: { include_banned: true },
signal
}
)
expect(mockClient.get).toHaveBeenNthCalledWith(3, '/nodes/pack/versions', {
params: { statuses: ['NodeVersionStatusActive'] },
signal
})
expect(mockClient.get).toHaveBeenNthCalledWith(
4,
'/nodes/pack/versions/version',
{ signal }
)
expect(mockClient.get).toHaveBeenNthCalledWith(5, '/nodes/pack', {
signal
})
expect(mockClient.get).toHaveBeenNthCalledWith(
6,
'/comfy-nodes/KSampler/node',
{ signal }
)
expect(mockClient.get).toHaveBeenNthCalledWith(7, '/nodes', {
params: { page: 1 },
signal
})
expect(mockClient.post).toHaveBeenNthCalledWith(
1,
'/nodes/pack/reviews',
null,
{ params: { star: 5 }, signal }
)
expect(mockClient.post).toHaveBeenNthCalledWith(
2,
'/bulk/nodes/versions',
{ node_versions: [{ node_id: 'pack', version: '1.0.0' }] },
{ signal }
)
})
it('omits include_banned when listing publisher packs without banned packs', async () => {
mockClient.get.mockResolvedValueOnce(response([]))
const service = useComfyRegistryService()
await service.listPacksForPublisher('publisher', false)
expect(mockClient.get).toHaveBeenCalledWith('/publishers/publisher/nodes', {
params: undefined,
signal: undefined
})
})
it.for([
{ status: 400, expected: 'Bad request: Invalid input' },
{ status: 401, expected: 'Unauthorized: Authentication required' },
{ status: 403, expected: 'Forbidden: Access denied' },
{ status: 404, expected: 'Not found: Resource not found' },
{ status: 409, expected: 'Conflict: Resource conflict' },
{ status: 500, expected: 'Server error: Internal server error' },
{ status: 418, expected: 'Failed to perform search: teapot' }
])(
'normalizes axios response status $status',
async ({ status, expected }) => {
mockClient.get.mockRejectedValueOnce(
axiosError('Request failed', {
status,
data: status === 418 ? { message: 'teapot' } : {}
})
)
const service = useComfyRegistryService()
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe(expected)
expect(service.isLoading.value).toBe(false)
}
)
it('uses route-specific errors before generic status messages', async () => {
mockClient.get.mockRejectedValueOnce(
axiosError('Request failed', {
status: 404,
data: { message: 'ignored' }
})
)
const service = useComfyRegistryService()
await expect(service.getPackById('missing')).resolves.toBeNull()
expect(service.error.value).toBe(
'Pack not found: The pack with ID missing does not exist'
)
})
it('normalizes network, thrown Error, unknown, and abort failures', async () => {
const service = useComfyRegistryService()
mockClient.get.mockRejectedValueOnce(axiosError('Network down'))
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe('Failed to perform search: Network down')
mockClient.get.mockRejectedValueOnce(new Error('boom'))
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe('Failed to perform search: boom')
mockClient.get.mockRejectedValueOnce('bad')
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBe(
'Failed to perform search: Unknown error occurred'
)
mockClient.get.mockRejectedValueOnce(new DOMException('', 'AbortError'))
await expect(service.search()).resolves.toBeNull()
expect(service.error.value).toBeNull()
})
})

View File

@@ -234,54 +234,6 @@ describe('useRegistrySearchGateway', () => {
const gateway = useRegistrySearchGateway()
expect(gateway).toBeDefined()
})
it('waits for the circuit breaker timeout before retrying a failed provider', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
throw new Error('Algolia init failed')
})
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
for (let attempt = 0; attempt < 3; attempt++) {
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).rejects.toThrow('All search providers failed')
}
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(3)
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).rejects.toThrow('All search providers failed')
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(3)
vi.setSystemTime(new Date('2024-01-01T00:01:01Z'))
mockRegistryProvider.searchPacks.mockResolvedValueOnce(registryResult)
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).resolves.toBe(registryResult)
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(4)
})
})
describe('Cache management', () => {

View File

@@ -126,19 +126,6 @@ describe('useAssetDownloadStore', () => {
})
})
it('keeps the first placeholder when the same task is tracked twice', () => {
const store = useAssetDownloadStore()
store.trackDownload('task-123', 'checkpoints', 'first.safetensors')
store.trackDownload('task-123', 'loras', 'second.safetensors')
expect(store.downloadList).toHaveLength(1)
expect(store.downloadList[0]).toMatchObject({
modelType: 'checkpoints',
assetName: 'first.safetensors'
})
})
it('handles out-of-order messages where completed arrives before progress', () => {
const store = useAssetDownloadStore()
@@ -192,19 +179,6 @@ describe('useAssetDownloadStore', () => {
expect(store.finishedDownloads[0].status).toBe('completed')
})
it('skips polling when active downloads have fresh progress', async () => {
const store = useAssetDownloadStore()
dispatch(createDownloadMessage({ status: 'running' }))
await vi.advanceTimersByTimeAsync(9_999)
dispatch(createDownloadMessage({ status: 'running', progress: 75 }))
await vi.advanceTimersByTimeAsync(1)
expect(taskService.getTask).not.toHaveBeenCalled()
expect(store.activeDownloads).toHaveLength(1)
expect(store.activeDownloads[0].progress).toBe(75)
})
it('polls and marks failed downloads', async () => {
const store = useAssetDownloadStore()
@@ -337,22 +311,5 @@ describe('useAssetDownloadStore', () => {
expect(store.sessionDownloadCount).toBe(0)
expect(store.isDownloadedThisSession('asset-456')).toBe(false)
})
it('does not acknowledge unrelated completed downloads', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: 'asset-456'
})
)
store.acknowledgeAsset('other-asset')
expect(store.sessionDownloadCount).toBe(1)
expect(store.isDownloadedThisSession('asset-456')).toBe(true)
})
})
})

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
@@ -12,7 +11,6 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
// Mock the api module
vi.mock('@/scripts/api', () => ({
@@ -98,10 +96,6 @@ const mockOutputOverrides = vi.hoisted(() => ({
value: null as MockOutput[] | null
}))
const mockAssetMapperOptions = vi.hoisted(() => ({
omitCreatedAtForIds: new Set<string>()
}))
// Mock TaskItemImpl
const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio'])
@@ -175,14 +169,11 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
})),
mapTaskOutputToAssetItem: vi.fn((task, output) => {
const index = parseInt(task.jobId.split('_')[1]) || 0
const createdAt = new Date(Date.now() - index * 1000).toISOString()
return {
id: task.jobId,
name: output.filename,
size: 0,
...(!mockAssetMapperOptions.omitCreatedAtForIds.has(task.jobId) && {
created_at: createdAt
}),
created_at: new Date(Date.now() - index * 1000).toISOString(),
tags: ['output'],
preview_url: output.url,
user_metadata: {}
@@ -214,7 +205,6 @@ describe('assetsStore - Refactored (Option A)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAssetsStore()
vi.clearAllMocks()
mockAssetMapperOptions.omitCreatedAtForIds.clear()
})
describe('Initial Load', () => {
@@ -282,17 +272,6 @@ describe('assetsStore - Refactored (Option A)', () => {
'prompt_2'
])
})
it('should skip unfinished jobs and completed jobs without previews', async () => {
vi.mocked(api.getHistory).mockResolvedValue([
{ ...createMockJobItem(0), status: 'in_progress' },
{ ...createMockJobItem(1), preview_output: undefined }
])
await store.updateHistory()
expect(store.historyAssets).toEqual([])
})
})
describe('Pagination', () => {
@@ -349,46 +328,6 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(uniqueAssetIds.size).toBe(store.historyAssets.length)
})
it('should insert newer paginated items in sorted order', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('sorts paginated items when the incoming asset has no timestamp', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
mockAssetMapperOptions.omitCreatedAtForIds.add('prompt_200')
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(200)])
await store.loadMoreHistory()
expect(store.historyAssets.at(-1)?.id).toBe('prompt_200')
})
it('sorts paginated items when an existing asset has no timestamp', async () => {
for (let i = 0; i < 200; i++) {
mockAssetMapperOptions.omitCreatedAtForIds.add(`prompt_${i}`)
}
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('should stop loading when no more items', async () => {
// First batch - less than BATCH_SIZE
const firstBatch = Array.from({ length: 50 }, (_, i) =>
@@ -555,29 +494,6 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(store.historyLoading).toBe(false)
expect(store.historyError).toBe(error)
})
it('should preserve existing history when refresh fails', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(0)])
await store.updateHistory()
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
expect(store.historyError).toBe(error)
})
it('should keep empty history when loadMore fails before any load', async () => {
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.loadMoreHistory()
expect(store.historyAssets).toEqual([])
expect(store.historyError).toBe(error)
})
})
describe('Memory Management', () => {
@@ -1008,43 +924,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).toHaveBeenCalledTimes(2)
})
it('ignores a model response after the category is invalidated', async () => {
const store = useAssetsStore()
let resolveFetch!: (assets: AssetItem[]) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((resolve) => {
resolveFetch = resolve
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
resolveFetch([createMockAsset('stale-response')])
await request
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
})
it('ignores a model rejection after the category is invalidated', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
let rejectFetch!: (error: Error) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectFetch = reject
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
rejectFetch(new Error('stale rejection'))
await request
expect(store.getError('CheckpointLoaderSimple')).toBeUndefined()
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('shallowReactive state reactivity', () => {
@@ -1087,10 +966,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
it('should return empty array for unknown node types', () => {
const store = useAssetsStore()
expect(store.getAssets('UnknownNodeType')).toEqual([])
expect(store.isModelLoading('UnknownNodeType')).toBe(false)
expect(store.getError('UnknownNodeType')).toBeUndefined()
expect(store.hasMore('UnknownNodeType')).toBe(false)
expect(store.hasAssetKey('UnknownNodeType')).toBe(false)
})
it('should not fetch for unknown node types', async () => {
@@ -1100,63 +975,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).not.toHaveBeenCalled()
})
it('should refresh an already loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('first')
])
await store.updateModelsForNodeType(nodeType)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('second')
])
await store.updateModelsForNodeType(nodeType)
expect(store.getAssets(nodeType).map((asset) => asset.id)).toEqual([
'second'
])
})
it('reports hasMore for a loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
expect(store.hasMore(nodeType)).toBe(false)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('only-page')
])
await store.updateModelsForNodeType(nodeType)
expect(store.hasMore(nodeType)).toBe(false)
})
it('should record model loading errors', async () => {
const store = useAssetsStore()
const error = new Error('model fetch failed')
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce(error)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')).toBe(error)
expect(store.isModelLoading('CheckpointLoaderSimple')).toBe(false)
consoleSpy.mockRestore()
})
it('should wrap non-error model loading failures', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce('boom')
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')?.message).toBe('boom')
consoleSpy.mockRestore()
})
})
describe('invalidateCategory', () => {
@@ -1311,140 +1129,7 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
})
})
describe('completed download refresh', () => {
it('refreshes provider and tag caches for the completed model type', async () => {
const store = useAssetsStore()
const downloadStore = useAssetDownloadStore()
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([])
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([])
downloadStore.lastCompletedDownload = {
taskId: 'task-1',
modelType: 'checkpoints',
timestamp: 1
}
await vi.waitFor(() =>
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'models',
true,
expect.objectContaining({ limit: 500, offset: 0 })
)
)
expect(assetService.getAssetsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
expect.objectContaining({ limit: 500, offset: 0 })
)
expect(assetService.getAssetsForNodeType).toHaveBeenCalledTimes(1)
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'checkpoints',
true,
expect.objectContaining({ limit: 500, offset: 0 })
)
expect(store.hasCategory('tag:models')).toBe(true)
})
})
describe('updateAssetMetadata optimistic cache', () => {
it('still writes metadata when a cache key is unresolved', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-unknown'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'after' }
})
await store.updateAssetMetadata(
original,
{ note: 'after' },
'UnknownNodeType'
)
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-unknown',
{ user_metadata: { note: 'after' } }
)
})
it('still updates the server when the asset is not cached', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-missing'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(original, { note: 'after' })
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-missing',
{ user_metadata: { note: 'after' } }
)
})
it('still updates the server when a resolved cache key has not loaded yet', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-unloaded'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(
original,
{ note: 'after' },
'CheckpointLoaderSimple'
)
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-unloaded',
{ user_metadata: { note: 'after' } }
)
})
it('leaves unrelated cached assets alone during optimistic metadata update', async () => {
const store = useAssetsStore()
const cached = {
...createMockAsset('opt-cached'),
user_metadata: { note: 'cached' } as Record<string, unknown>
}
const missing = {
...createMockAsset('opt-missing-from-cache'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
cached
])
await store.updateModelsForNodeType('CheckpointLoaderSimple')
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...missing,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(
missing,
{ note: 'after' },
'CheckpointLoaderSimple'
)
expect(
store.getAssets('CheckpointLoaderSimple')[0].user_metadata
).toEqual({
note: 'cached'
})
})
it('reflects the server response in the cache after a successful update', async () => {
const store = useAssetsStore()
const original = {
@@ -1552,31 +1237,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
'featured'
])
})
it('calls only the remove endpoint when there are no tags to add', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-remove-only', ['models', 'archived'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('CheckpointLoaderSimple')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
total_tags: ['models']
})
await store.updateAssetTags(asset, ['models'], 'CheckpointLoaderSimple')
expect(vi.mocked(assetService.removeAssetTags)).toHaveBeenCalledWith(
'tags-remove-only',
['archived']
)
expect(vi.mocked(assetService.addAssetTags)).not.toHaveBeenCalled()
expect(store.getAssets('CheckpointLoaderSimple')[0].tags).toEqual([
'models'
])
})
})
describe('updateAssetTags partial-failure compensation', () => {
@@ -1691,36 +1351,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.hasCategory('tag:models')).toBe(false)
})
it('keeps unrelated tag caches when compensation fails with a cache key', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-target-fail', ['models', 'loras'])
const otherAsset = createMockAsset('tags-other', ['models'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('LoraLoader')
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([otherAsset])
await store.updateModelsForTag('models')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
removed: ['loras'],
total_tags: ['models']
})
vi.mocked(assetService.addAssetTags)
.mockRejectedValueOnce(new Error('500 add failed'))
.mockRejectedValueOnce(new Error('503 compensation failed'))
await store.updateAssetTags(
asset,
['models', 'checkpoints'],
'LoraLoader'
)
expect(store.hasCategory('loras')).toBe(false)
expect(store.hasCategory('tag:models')).toBe(true)
})
it('does not attempt compensation when only the add was attempted', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-add-only-fail', ['models'])
@@ -1853,78 +1483,9 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
const store = useAssetsStore()
expect(store.getInputName('unknown.png')).toBe('unknown.png')
})
it('ignores input assets without hashes', async () => {
mockIsCloud.value = true
try {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
{
id: 'input-1',
name: 'plain.png',
tags: ['input']
}
])
await store.updateInputs()
expect(store.getInputName('plain.png')).toBe('plain.png')
} finally {
mockIsCloud.value = false
}
})
})
describe('updateInputs cloud routing', () => {
it('reads input files from the internal API when isCloud is false', async () => {
const fetchMock = vi.fn().mockResolvedValue(
fromAny<Response, unknown>({
ok: true,
json: async () => ['input-a.png', 'input-b.png']
})
)
vi.stubGlobal('fetch', fetchMock)
try {
const store = useAssetsStore()
await store.updateInputs()
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/files/input',
{ headers: { 'Comfy-User': 'test-user' } }
)
expect(store.inputAssets.map((asset) => asset.name)).toEqual([
'input-a.png',
'input-b.png'
])
} finally {
vi.unstubAllGlobals()
}
})
it('records internal input API failures', async () => {
const fetchMock = vi.fn().mockResolvedValue(
fromAny<Response, unknown>({
ok: false
})
)
vi.stubGlobal('fetch', fetchMock)
try {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const store = useAssetsStore()
await store.updateInputs()
expect(store.inputError).toBeInstanceOf(Error)
consoleSpy.mockRestore()
} finally {
vi.unstubAllGlobals()
}
})
it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => {
mockIsCloud.value = true
try {
@@ -2025,18 +1586,6 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputHasMore).toBe(false)
})
it('does not load more flat outputs when there are no more pages', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'one.png')])
)
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(1)
})
it('threads the minted cursor into after on loadMore and omits offset', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
@@ -2251,26 +1800,4 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
})
it('ignores concurrent load more calls while one is active', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], { hasMore: true })
)
const store = useAssetsStore()
await store.updateFlatOutputs()
let resolvePage!: (page: AssetResponse) => void
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(
new Promise<AssetResponse>((resolve) => {
resolvePage = resolve
})
)
const first = store.loadMoreFlatOutputs()
const second = store.loadMoreFlatOutputs()
resolvePage(makePage([makeAsset('a2', 'f2.png')]))
await Promise.all([first, second])
expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -178,10 +177,9 @@ describe('useComfyRegistryStore', () => {
it('should return null when fetching a pack with null ID', async () => {
const store = useComfyRegistryStore()
vi.spyOn(store.getPackById, 'call').mockResolvedValueOnce(null)
const result = await store.getPackById.call(
fromAny<Parameters<typeof store.getPackById.call>[0], unknown>(null)
)
const result = await store.getPackById.call(null!)
expect(result).toBeNull()
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()
@@ -208,56 +206,6 @@ describe('useComfyRegistryStore', () => {
)
})
it('should reuse cached packs by ID', async () => {
const store = useComfyRegistryStore()
await store.getPacksByIds.call(['test-pack-id'])
const result = await store.getPacksByIds.call(['test-pack-id'])
expect(result).toEqual([mockNodePack])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledTimes(1)
})
it('should ignore missing packs by ID', async () => {
mockRegistryService.listAllPacks.mockResolvedValueOnce({
nodes: [fromAny<components['schemas']['Node'], unknown>({ name: 'bad' })],
total: 1,
page: 1,
limit: 10
})
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(['unknown-pack-id'])
expect(result).toEqual([])
})
it('should handle empty pack lookup responses', async () => {
mockRegistryService.listAllPacks.mockResolvedValueOnce(null)
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(['unknown-pack-id'])
expect(result).toEqual([])
})
it('should filter undefined pack IDs before lookup', async () => {
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(
fromAny<components['schemas']['Node']['id'][], unknown>([
'test-pack-id',
undefined
])
)
expect(result).toEqual([mockNodePack])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledWith(
{ node_id: ['test-pack-id'] },
expect.any(Object)
)
})
describe('inferPackFromNodeName', () => {
it('should fetch a pack by comfy node name', async () => {
const store = useComfyRegistryStore()

View File

@@ -137,88 +137,6 @@ describe('useModelStore', () => {
expect(model.resolution).toBe('')
})
it('keeps the default model metadata when the server returns null', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce(null)
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
expect(model.has_loaded_metadata).toBe(false)
})
it('loads model metadata once', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
await model.load()
expect(api.viewMetadata).toHaveBeenCalledTimes(1)
})
it('keeps the default title when the first metadata key is empty', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'modelspec.title': '',
display_name: 'Fallback title'
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
})
it('returns null for unknown loaded model folders', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await expect(store.getLoadedModelFolder('missing')).resolves.toBeNull()
})
it('should read metadata from suffixed keys and ignore null values', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'custom.modelspec.title': 'Namespaced title',
'custom.modelspec.author': null,
'custom.modelspec.tags': null
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('Namespaced title')
expect(model.author).toBe('')
expect(model.tags).toEqual([''])
})
it('should keep extensions for non-safetensors files', async () => {
enableMocks()
vi.mocked(api.getModels).mockResolvedValueOnce([
{ name: 'notes.txt', pathIndex: 0 }
])
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore!.models['0/notes.txt'].title).toBe('notes.txt')
})
it('should cache model information', async () => {
enableMocks()
store = useModelStore()
@@ -291,23 +209,6 @@ describe('useModelStore', () => {
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(api.getModels).not.toHaveBeenCalled()
})
it('does not reload previously loaded folders that disappear', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
vi.mocked(api.getModelFolders).mockResolvedValueOnce([
{ name: 'vae', folders: ['/path/to/vae'] }
])
await store.refresh()
expect(store.modelFolders.map((folder) => folder.directory)).toEqual([
'vae'
])
expect(api.getModels).toHaveBeenCalledTimes(1)
})
})
describe('API switching functionality', () => {

View File

@@ -138,22 +138,6 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('ckpt_name')
})
it('omits providers whose node definition is unavailable from reverse lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(modelToNodeStore.getRegisteredNodeTypes()).not.toHaveProperty(
'undefined'
)
})
it('should return undefined for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
@@ -593,22 +577,6 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('skips providers without node definitions during category lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(
modelToNodeStore.getCategoryForNodeType('MissingNode')
).toBeUndefined()
})
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()

View File

@@ -1,24 +1,16 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import axios from 'axios'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
createDummyFolderNodeDef,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
@@ -29,10 +21,6 @@ describe('useNodeDefStore', () => {
store = useNodeDefStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef => ({
@@ -51,112 +39,7 @@ describe('useNodeDefStore', () => {
...overrides
})
describe('ComfyNodeDefImpl', () => {
it('migrates defaultInput options and applies constructor fallbacks', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const nodeDef = createMockNodeDef({
category: '_for_testing/coverage',
deprecated: undefined,
dev_only: undefined,
experimental: undefined,
help: undefined,
input: {
required: { prompt: ['STRING', { defaultInput: true }] },
optional: { seed_override: ['INT', { defaultInput: true }] }
}
})
const impl = new ComfyNodeDefImpl(nodeDef)
expect(warn).toHaveBeenCalledTimes(2)
expect(impl.help).toBe('')
expect(impl.experimental).toBe(true)
expect(impl.dev_only).toBe(false)
expect(impl.inputs.seed_override.forceInput).toBe(true)
})
it('derives empty-category node paths and lifecycle badges', () => {
const deprecated = new ComfyNodeDefImpl(
createMockNodeDef({ category: '', deprecated: undefined })
)
const beta = new ComfyNodeDefImpl(
createMockNodeDef({ experimental: true })
)
const dev = new ComfyNodeDefImpl(createMockNodeDef({ dev_only: true }))
const normal = new ComfyNodeDefImpl(createMockNodeDef())
expect(deprecated.nodePath).toBe('TestNode')
expect(deprecated.isDummyFolder).toBe(false)
expect(deprecated.nodeLifeCycleBadgeText).toBe('[DEPR]')
expect(beta.nodeLifeCycleBadgeText).toBe('[BETA]')
expect(dev.nodeLifeCycleBadgeText).toBe('[DEV]')
expect(normal.nodeLifeCycleBadgeText).toBe('')
})
it('defaults missing legacy input and output fields', () => {
const nodeDef = new ComfyNodeDefImpl(
fromAny<ComfyNodeDef, unknown>({
name: 'FallbackNode',
display_name: 'Fallback Node',
category: 'test',
python_module: 'test_module',
description: 'Test node',
output_node: false
})
)
expect(nodeDef.input).toEqual({})
expect(nodeDef.output).toEqual([])
})
it('post-processes search scores with node frequency', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({ data: { TestNode: 7 } })
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
const nodeDef = new ComfyNodeDefImpl(createMockNodeDef())
expect(nodeDef.postProcessSearchScores([10, 4, 2])).toEqual([
10, -7, 4, 2
])
})
})
describe('tree helpers', () => {
it('builds node definition trees from default and custom paths', () => {
const nodeDef = new ComfyNodeDefImpl(
createMockNodeDef({ name: 'TreeNode', category: 'root/branch' })
)
expect(buildNodeDefTree([nodeDef]).children?.[0].label).toBe('root')
expect(
buildNodeDefTree([nodeDef], {
pathExtractor: (node) => ['custom', node.name]
}).children?.[0].label
).toBe('custom')
})
it('normalizes dummy folder paths', () => {
expect(createDummyFolderNodeDef('folder/').category).toBe('folder')
expect(createDummyFolderNodeDef('folder').category).toBe('folder')
})
})
describe('filter registry', () => {
it('updates LiteGraph skip state for registered dev-only nodes', () => {
const registeredNodeTypes = LiteGraph.registered_node_types
LiteGraph.registered_node_types = fromAny({
DevNode: { nodeData: { dev_only: true }, skip_list: false },
NormalNode: { nodeData: {}, skip_list: false }
})
setActivePinia(createTestingPinia({ stubActions: false }))
useNodeDefStore()
expect(LiteGraph.registered_node_types.DevNode.skip_list).toBe(true)
expect(LiteGraph.registered_node_types.NormalNode.skip_list).toBe(false)
LiteGraph.registered_node_types = registeredNodeTypes
})
it('should register a new filter', () => {
const filter: NodeDefFilter = {
id: 'test.filter',
@@ -404,26 +287,6 @@ describe('useNodeDefStore', () => {
})
describe('allNodeDefsByName', () => {
it('keeps existing ComfyNodeDefImpl instances during updates', () => {
const nodeDef = new ComfyNodeDefImpl(
createMockNodeDef({ name: 'ExistingImpl' })
)
store.updateNodeDefs([nodeDef])
expect(store.nodeDefsByName.ExistingImpl.name).toBe('ExistingImpl')
expect(store.nodeDefsByDisplayName['Test Node'].name).toBe('ExistingImpl')
})
it('adds one node definition to the name and display-name indexes', () => {
store.addNodeDef(
createMockNodeDef({ name: 'AddedNode', display_name: 'Added Node' })
)
expect(store.nodeDefsByName.AddedNode.name).toBe('AddedNode')
expect(store.nodeDefsByDisplayName['Added Node'].name).toBe('AddedNode')
})
it('should include all node defs by name', () => {
const node1 = createMockNodeDef({ name: 'Node1' })
const node2 = createMockNodeDef({ name: 'Node2' })
@@ -473,39 +336,6 @@ describe('useNodeDefStore', () => {
expect(store.allNodeDefsByName).toHaveProperty('Normal')
expect(store.allNodeDefsByName).toHaveProperty('Deprecated')
})
it('derives unique input and output data types', () => {
store.updateNodeDefs([
createMockNodeDef({
input: {
required: { image: ['IMAGE', {}] },
optional: { mask: ['MASK', {}] }
},
output: ['IMAGE', 'LATENT'],
output_is_list: [false, false],
output_name: ['image', 'latent']
})
])
expect([...store.nodeDataTypes].sort()).toEqual([
'IMAGE',
'LATENT',
'MASK'
])
})
it('looks up node definitions from graph nodes and returns null for misses', () => {
store.updateNodeDefs([createMockNodeDef({ name: 'KnownNode' })])
expect(
store.fromLGraphNode(new LGraphNode('KnownNode', 'KnownNode'))?.name
).toBe('KnownNode')
expect(store.fromLGraphNode(new LGraphNode('', ''))).toBeNull()
expect(
store.getInputSpecForWidget(new LGraphNode('Missing', 'Missing'), 'x')
).toBeUndefined()
expect(store.nodeSearchService).toBeDefined()
})
})
describe('subgraph widget input specs', () => {
@@ -559,94 +389,6 @@ describe('useNodeDefStore', () => {
expect(spec?.type).toBe('STRING')
expect(spec?.default).toBeUndefined()
})
it('returns undefined for missing promoted subgraph inputs', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'missing')).toBeUndefined()
})
it('returns undefined when a subgraph input is not promoted', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
host.addInput('raw', 'STRING')
expect(store.getInputSpecForWidget(host, 'raw')).toBeUndefined()
})
it('returns undefined when a promoted source no longer resolves', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
host.subgraph.nodes[0].widgets = []
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
it('returns undefined when concrete promoted widget resolution fails', async () => {
const resolver =
await import('@/core/graph/subgraph/resolveConcretePromotedWidget')
vi.spyOn(resolver, 'resolveConcretePromotedWidget').mockReturnValue(
fromAny({ status: 'failure', failure: 'missing-widget' })
)
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
})
describe('node frequency store', () => {
it('loads frequencies once and exposes top matching node definitions', async () => {
const get = vi.spyOn(axios, 'get').mockResolvedValue({
data: { RankedNode: 10, MissingNode: 3 }
})
store.updateNodeDefs([createMockNodeDef({ name: 'RankedNode' })])
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
await frequencyStore.loadNodeFrequencies()
expect(get).toHaveBeenCalledTimes(1)
expect(frequencyStore.isLoaded).toBe(true)
expect(frequencyStore.getNodeFrequencyByName('RankedNode')).toBe(10)
expect(
frequencyStore.getNodeFrequency(
new ComfyNodeDefImpl(createMockNodeDef({ name: 'RankedNode' }))
)
).toBe(10)
expect(frequencyStore.getNodeFrequencyByName('Unknown')).toBe(0)
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
['RankedNode']
)
})
it('leaves frequency state unloaded when loading fails', async () => {
const error = new Error('boom')
vi.spyOn(axios, 'get').mockRejectedValue(error)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
expect(frequencyStore.isLoaded).toBe(false)
expect(errorSpy).toHaveBeenCalledWith(
'Error loading node frequencies:',
error
)
})
})
describe('performance', () => {

View File

@@ -3,41 +3,15 @@ import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import * as litegraphUtil from '@/utils/litegraphUtil'
const {
mockApiURL,
mockExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeToNodeLocatorId,
mockReleaseSharedObjectUrl,
mockRetainSharedObjectUrl
} = vi.hoisted(() => ({
mockApiURL: vi.fn((path: string) => `api${path}`),
mockExecutionIdToNodeLocatorId: vi.fn(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
),
mockNodeIdToNodeLocatorId: vi.fn(
(id: string | number) => String(id) as NodeLocatorId
),
mockNodeToNodeLocatorId: vi.fn(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
),
mockReleaseSharedObjectUrl: vi.fn(),
mockRetainSharedObjectUrl: vi.fn()
}))
const mockResolveNode = vi.fn()
vi.mock('@/utils/litegraphUtil', () => ({
@@ -46,25 +20,11 @@ vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: (...args: unknown[]) => mockResolveNode(...args)
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (...args: Parameters<typeof mockApiURL>) => mockApiURL(...args)
}
}))
vi.mock('@/utils/objectUrlUtil', () => ({
releaseSharedObjectUrl: (...args: [string | undefined]) =>
mockReleaseSharedObjectUrl(...args),
retainSharedObjectUrl: (...args: [string | undefined]) =>
mockRetainSharedObjectUrl(...args)
}))
const mockGetNodeById = vi.fn()
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
getRandParam: vi.fn(() => '&rand=1'),
rootGraph: {
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
},
@@ -89,31 +49,13 @@ const createMockOutputs = (
): ExecutedWsMessage['output'] => ({ images })
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: (
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
) => mockExecutionIdToNodeLocatorId(...args)
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
}))
beforeEach(() => {
mockExecutionIdToNodeLocatorId.mockImplementation(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
)
mockNodeIdToNodeLocatorId.mockImplementation(
(id: string | number) => String(id) as NodeLocatorId
)
mockNodeToNodeLocatorId.mockImplementation(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
)
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: (
...args: Parameters<typeof mockNodeIdToNodeLocatorId>
) => mockNodeIdToNodeLocatorId(...args),
nodeToNodeLocatorId: (
...args: Parameters<typeof mockNodeToNodeLocatorId>
) => mockNodeToNodeLocatorId(...args)
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
}))
}))
@@ -838,19 +780,6 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']?.images?.[0]?.type).toBe('input')
})
it('ignores widget outputs when no locator can be resolved', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
mockNodeToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.setNodeOutputs(node, 'test.png')
expect(store.nodeOutputs).toEqual({})
expect(app.nodeOutputs).toEqual({})
})
it('should skip empty array of filenames after createOutputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
@@ -860,470 +789,6 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
it('stores direct result items without wrapping them as image outputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, { filename: 'direct.png', type: 'temp' })
expect(store.nodeOutputs['5']).toEqual({
filename: 'direct.png',
type: 'temp'
})
})
it('marks animated webp and png filenames when requested', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, ['clip.webp', 'still.jpg', 'mask.png'], {
folder: 'output',
isAnimated: true
})
expect(store.nodeOutputs['5']?.animated).toEqual([true, false, true])
expect(store.nodeOutputs['5']?.images?.map((image) => image.type)).toEqual([
'output',
'output',
'output'
])
})
})
describe('nodeOutputStore image URLs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('returns stored preview URLs before output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)), [
'blob:preview'
])
expect(store.getNodeImageUrls(node)).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('builds view URLs from output images', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
app.nodeOutputs['5'] = createMockOutputs(
fromAny([{ filename: 'a.png', subfolder: 'x', type: 'temp' }, null])
)
expect(store.getNodeImageUrls(node)).toEqual([
'api/view?filename=a.png&subfolder=x&type=temp&format=test_webp&rand=1'
])
})
it('returns undefined when a node has neither previews nor outputs', () => {
const store = useNodeOutputStore()
expect(store.getNodeImageUrls(createMockNode({ id: 5 }))).toBeUndefined()
})
it('returns execution previews before execution output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'blob:preview'
])
expect(store.latestPreview).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('falls back to execution output URLs when no preview exists', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'api/view?filename=result.png&type=temp&format=test_webp&rand=1'
])
})
})
describe('nodeOutputStore locator misses', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('keeps execution operations inert when no locator can be resolved', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
store.revokePreviewsByExecutionId(executionId)
expect(store.getNodeOutputByExecutionId(executionId)).toBeUndefined()
expect(store.getNodePreviewImagesByExecutionId(executionId)).toBeUndefined()
expect(store.nodeOutputs).toEqual({})
expect(store.nodePreviewImages).toEqual({})
})
})
describe('nodeOutputStore merge branches', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('sets outputs when merge is requested without existing output', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
const output = createMockOutputs([{ filename: 'first.png' }])
store.setNodeOutputsByExecutionId(executionId, output, { merge: true })
expect(store.nodeOutputs[executionId]).toEqual(output)
})
it('ignores null outputs', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
fromAny<ExecutedWsMessage['output'], unknown>(null)
)
expect(store.nodeOutputs[executionId]).toBeUndefined()
})
it('overwrites non-array fields during merge', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
const firstOutput: ExecutedWsMessage['output'] = {
images: [{ filename: 'first.png' }],
text: 'old'
}
store.setNodeOutputsByExecutionId(executionId, firstOutput)
store.setNodeOutputsByExecutionId(
executionId,
{ text: ['new'] },
{ merge: true }
)
expect(store.nodeOutputs[executionId]?.images).toEqual([
{ filename: 'first.png' }
])
expect(store.nodeOutputs[executionId]?.text).toEqual(['new'])
})
})
describe('nodeOutputStore previews and removal', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('releases old previews and retains new previews on replacement', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(5))
store.setNodePreviewsByLocatorId(locatorId, ['blob:first'])
store.setNodePreviewsByLocatorId(locatorId, ['blob:second'])
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first')
expect(mockRetainSharedObjectUrl).toHaveBeenCalledWith('blob:second')
expect(store.nodePreviewImages[locatorId]).toEqual(['blob:second'])
})
it('starts with an empty preview map when legacy previews are missing', () => {
app.nodePreviewImages = fromAny(undefined)
const store = useNodeOutputStore()
expect(store.nodePreviewImages).toEqual({})
})
it('cancels scheduled revocation when a newer preview arrives', async () => {
vi.useFakeTimers()
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodePreviewsByExecutionId(executionId, ['blob:first'])
store.revokePreviewsByExecutionId(executionId)
store.setNodePreviewsByExecutionId(executionId, ['blob:second'])
await vi.advanceTimersByTimeAsync(400)
vi.useRealTimers()
expect(store.nodePreviewImages[executionId]).toEqual(['blob:second'])
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalledWith('blob:second')
})
it('revokes locator previews and clears preview state', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(5))
store.setNodePreviewsByLocatorId(locatorId, ['blob:first'])
store.revokePreviewsByLocatorId(locatorId)
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first')
expect(store.nodePreviewImages[locatorId]).toBeUndefined()
expect(app.nodePreviewImages[locatorId]).toBeUndefined()
})
it('leaves state unchanged when revoking a locator with no previews', () => {
const store = useNodeOutputStore()
store.revokePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)))
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled()
expect(store.nodePreviewImages).toEqual({})
})
it('skips non-iterable preview entries when revoking all previews', () => {
const store = useNodeOutputStore()
app.nodePreviewImages = fromAny({
'5': {},
'6': ['blob:preview']
})
store.revokeAllPreviews()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledTimes(1)
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview')
expect(store.nodePreviewImages).toEqual({})
})
it('revokes subgraph previews for the parent node and child nodes', () => {
const store = useNodeOutputStore()
const subgraphId = '11111111-1111-1111-1111-111111111111'
const parentLocatorId = createNodeLocatorId(null, toNodeId(9))
const childLocatorId = createNodeLocatorId(subgraphId, toNodeId(10))
const subgraphNode = fromAny<SubgraphNode, unknown>({
id: toNodeId(9),
graph: { isRootGraph: true },
subgraph: {
id: subgraphId,
nodes: [createMockNode({ id: 10 })]
}
})
store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent'])
store.setNodePreviewsByLocatorId(childLocatorId, ['blob:child'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined()
expect(store.nodePreviewImages[childLocatorId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:parent')
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:child')
})
it('uses the parent graph id for non-root subgraph preview revocation', () => {
const store = useNodeOutputStore()
const graphId = '22222222-2222-2222-2222-222222222222'
const subgraphId = '33333333-3333-3333-3333-333333333333'
const parentLocatorId = createNodeLocatorId(graphId, toNodeId(9))
const subgraphNode = fromAny<SubgraphNode, unknown>({
id: toNodeId(9),
graph: { id: graphId, isRootGraph: false },
subgraph: { id: subgraphId, nodes: [] }
})
store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined()
})
it('leaves previews alone when a subgraph node has no parent graph', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(9))
const subgraphNode = fromAny<SubgraphNode, unknown>({
graph: undefined,
subgraph: { nodes: [] }
})
store.setNodePreviewsByLocatorId(locatorId, ['blob:parent'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[locatorId]).toEqual(['blob:parent'])
})
it('removes outputs and previews for a node id', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
expect(store.removeNodeOutputs(toNodeId(5))).toBe(true)
expect(store.nodeOutputs[executionId]).toBeUndefined()
expect(store.nodePreviewImages[executionId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview')
})
it('returns false when removing outputs for a node with no outputs', () => {
const store = useNodeOutputStore()
expect(store.removeNodeOutputsForNode(createMockNode({ id: 9 }))).toBe(
false
)
})
it('returns false when a node id cannot resolve to a locator', () => {
const store = useNodeOutputStore()
mockNodeIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
expect(store.removeNodeOutputs(toNodeId(9))).toBe(false)
})
it('removes preview state even when preview entries are not iterable', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
app.nodePreviewImages[executionId] = fromAny({})
store.nodePreviewImages[executionId] = fromAny({})
expect(store.removeNodeOutputs(toNodeId(5))).toBe(true)
expect(store.nodePreviewImages[executionId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled()
})
})
describe('nodeOutputStore output refresh', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('updates stored output images from legacy node images', () => {
const store = useNodeOutputStore()
const node = createMockNode({
id: 5,
images: [{ filename: 'new.png', type: 'temp' }]
})
store.setNodeOutputsByExecutionId(
createNodeExecutionId([toNodeId(5)]),
createMockOutputs([{ filename: 'old.png', type: 'temp' }])
)
store.updateNodeImages(node)
expect(store.nodeOutputs['5']?.images).toEqual([
{ filename: 'new.png', type: 'temp' }
])
})
it('ignores legacy image updates when the node has no images', () => {
const store = useNodeOutputStore()
store.updateNodeImages(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('ignores legacy image updates when no locator exists', () => {
const store = useNodeOutputStore()
mockNodeIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.updateNodeImages(
createMockNode({ id: 5, images: [{ filename: 'new.png' }] })
)
expect(store.nodeOutputs).toEqual({})
})
it('ignores legacy image updates when no output exists', () => {
const store = useNodeOutputStore()
store.updateNodeImages(
createMockNode({ id: 5, images: [{ filename: 'new.png' }] })
)
expect(store.nodeOutputs).toEqual({})
})
it('copies app outputs into reactive state during refresh', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const output = createMockOutputs([{ filename: 'result.png' }])
app.nodeOutputs['5'] = output
store.refreshNodeOutputs(node)
expect(store.nodeOutputs['5']).toEqual(output)
expect(store.nodeOutputs['5']).not.toBe(output)
})
it('does not refresh when a node has no locator', () => {
const store = useNodeOutputStore()
mockNodeToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.refreshNodeOutputs(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('does not refresh when app has no output for the node', () => {
const store = useNodeOutputStore()
store.refreshNodeOutputs(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('keeps unresolved restore output ids as their original ids', () => {
const store = useNodeOutputStore()
const output = createMockOutputs([{ filename: 'saved.png' }])
mockExecutionIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.restoreOutputs({ missing: output })
expect(store.nodeOutputs.missing).toEqual(output)
})
})
describe('nodeOutputStore syncLegacyNodeImgs', () => {
@@ -1429,20 +894,4 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})
it('copies output images onto the legacy node', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockResolveNode.mockReturnValue(mockNode)
store.setNodeOutputsByExecutionId(
createNodeExecutionId([toNodeId(1)]),
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
store.syncLegacyNodeImgs(toNodeId(1), mockImg)
expect(mockNode.images).toEqual([{ filename: 'result.png', type: 'temp' }])
})
})

View File

@@ -95,22 +95,6 @@ describe(usePreviewExposureStore, () => {
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
})
it('clears only the requested host when other hosts remain', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
store.addExposure(rootGraphA, hostB, {
sourceNodeId: '43',
sourcePreviewName: 'preview'
})
store.setExposures(rootGraphA, hostA, [])
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
})
})
describe('removeExposure', () => {
@@ -138,12 +122,6 @@ describe(usePreviewExposureStore, () => {
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
})
it('is a no-op for an unknown host', () => {
store.removeExposure(rootGraphA, 'missing-host', 'preview')
expect(store.getExposures(rootGraphA, 'missing-host')).toEqual([])
})
})
describe('getExposuresAsPromotionShape', () => {

View File

@@ -25,13 +25,15 @@ export enum ServerFeatureFlag {
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
}
export function useFeatureFlags() {
return {
flags: {
teamWorkspacesEnabled: true
teamWorkspacesEnabled: true,
consolidatedBillingEnabled: true
}
}
}