Compare commits

...

44 Commits

Author SHA1 Message Date
Mobeen Abdullah
0aa24e4e34 feat(website): add page-specific JSON-LD to the education page (#13732)
## Summary

Brings the `/education` page up to the site-wide JSON-LD standard. It
was still being built when the site-wide rollout (#13480) landed, so it
only had a hand-rolled FAQPage in the head slot, no breadcrumbs, and no
markup for the education offer.

## Changes

- **What**: `/education` (en + zh-CN) now emits one connected `@graph`
via `BaseLayout`: a `Product` ("Comfy for Education") whose `offers` are
the visible discounted student/educator plans, plus `Home > Education`
breadcrumbs, with the FAQ moved from a separate head-slot script into
the same graph as a `FAQPage` node. `WebPage.mainEntity` points at the
Product.
- New builders: `faqPageNode` in `utils/jsonLd.ts`,
`educationOffers(locale)` in `config/pricing.ts` (shares the list-price
helper; the pricing page is unchanged).
- **Breaking**: none.

## Review Focus

- **Honesty**: the offers use the visible discounted monthly education
prices ($18 / $31.50 / $90 USD), matching how `/cloud/pricing` marks up
its monthly Product. No `Course` node — the page is a program/pricing
landing page, not a curriculum, so a Course would overreach.
- The FAQPage now rides in the single graph instead of a standalone
script. The markup stays valid; note Google restricted FAQ rich results
to gov/health sites in 2023, so this is valid structured data rather
than a rich-result play (unchanged by this PR, the FAQ markup was
already there).
- `/cloud/pricing` is intentionally untouched: it already carries its
`productNode` from #13480.

## Verification

- typecheck (astro check): 0 errors
- test:unit: 224/224 (+2: `faqPageNode`, `educationOffers`)
- build: 505 pages; validate:jsonld: passed across 508 pages
- e2e `education.spec.ts` (desktop): 16/16, incl. the updated FAQPage
test and a new priced-Product/breadcrumb test
- Built HTML (en + zh-CN): one graph `Organization -> WebSite -> WebPage
-> BreadcrumbList -> Product -> FAQPage`, one Organization,
`WebPage.mainEntity` -> Product, offers priced in USD, 14 FAQ questions,
localized breadcrumbs (Home/Education, 首页/教育), all `@id`s resolve.

No visual change (JSON-LD is not rendered).
2026-07-18 01:19:40 +05:00
Mobeen Abdullah
072d48c954 feat(website): add Chinese translations for the five Creative Campus customer stories (#13729)
## Summary

Adds the Simplified Chinese (`zh-CN`) versions of the five Creative
Campus customer stories, which currently exist in English only, so the
zh-CN education and customers pages reach content parity with English.

## Changes

- **What**: 5 new MDX files under
`apps/website/src/content/customers/zh-CN/` (`golan-levin`,
`ina-conradi`, `kathy-smith`, `ual-cci`, `xindi-zhang`), mirroring the
English sources exactly (same frontmatter keys, section ids, components,
image/asset URLs, and `order`). Only human-readable text is translated;
product and proper nouns (ComfyUI, Blender, NTU, USC, film/work titles,
people's names) are kept in English, matching the existing zh-CN
stories.
- No component, page, schema, or config changes. Content-only.

This closes the zh-CN content-parity gap flagged during the #13406
review (the `/zh-CN/education` customer-showcase band was empty because
these five slugs existed only in `en/`). The JSON-LD for the new zh-CN
pages is emitted automatically by the existing site-wide builder
(`utils/jsonLd.ts` via `BaseLayout`), so no schema code was needed here.

## Review Focus

- **Native-speaker proof recommended before the education launch.**
These are long-form interviews quoting named academics; the translations
match the existing zh-CN voice, but a native pass would harden the
professor quotes and marketing copy. Who owns zh translation-quality
sign-off?
- JSON-LD (built HTML, both locales): each new zh-CN detail page emits
one connected `@graph` (Organization -> WebSite -> WebPage ->
BreadcrumbList -> Article), one Organization, `Article.inLanguage =
"zh-CN"`, translated headline/description, Chinese breadcrumbs, all
`@id`s resolvable. The `/zh-CN/customers` ItemList is now 10, at parity
with English.

## Verification

- typecheck (astro check): 0 errors
- build: 510 pages (5 new zh-CN detail pages)
- validate:jsonld: passed across 513 pages
- test:unit: 242/242 (incl. cross-locale content section-id parity test)
- e2e `customers` + `customers-detail` (desktop): 8/8, incl. the
Creative Campus education-blocks and JSON-LD graph tests
- format:check and knip: clean

## Screenshots

<!-- zh-CN /education showcase band, /zh-CN/customers listing, and zh-CN
detail pages (desktop + mobile) to be attached -->
2026-07-17 21:55:19 +05:00
Michael B
e9eac5ccce Merge remote-tracking branch 'origin/feat/edu-page' into feat/edu-page
# Conflicts:
#	apps/website/src/pages/customers.astro
#	apps/website/src/pages/customers/[slug].astro
#	apps/website/src/pages/zh-CN/customers.astro
#	apps/website/src/pages/zh-CN/customers/[slug].astro
2026-07-14 09:30:52 -04:00
Michael B
0c8ad1cb9e fix(website): align education page title with hyphen convention 2026-07-14 09:28:33 -04:00
Michael B
7026b1676c Merge remote-tracking branch 'origin/main' into feat/edu-page 2026-07-14 09:26:48 -04:00
Mobeen Abdullah
82a29d4c47 feat(website): add JSON-LD structured data for customer pages (#13468)
## Summary

Add JSON-LD structured data to the customers listing and story detail
pages (en + zh-CN) using the now-merged site-wide schema builder. This
is the FE-1169 work rebuilt on `feat/edu-page` so it ships with the
education bundle; it supersedes #13404 (which predates the site-wide
standard and will be closed). Base is `feat/edu-page`.

## Changes

- **What**: The customer pages now feed the shared `BaseLayout` the
standard JSON-LD props (`pageType`, `breadcrumbs`, `mainEntityId`,
`extraJsonLd`) instead of building their own separate graph, exactly
like the other enriched pages (`p/supported-models`, etc.). The listing
pages emit `CollectionPage` + `ItemList`; the detail pages emit an
`Article` and now also pass the story `description` and `cover`, so the
page's `WebPage` node, meta description, and social image reflect the
actual story rather than the site defaults.
- One new builder, `articleNode`, added to `utils/jsonLd.ts` (the only
node type the site-wide builder did not already cover), reusing the
existing `jsonLdId` / `organizationId` helpers.
- Reuses the existing `breadcrumb.home` and `nav.customerStories` i18n
keys, so no translation or routes changes are needed.
- Removes the old bespoke `utils/customerStoryJsonLd.ts` builder; the
earlier standalone approach (self-contained per-page graph +
`includeDefaultJsonLd` opt-out) is replaced by the standardized one that
landed in #13480.
- **Breaking**: none.
- **Dependencies**: none.

## Review Focus

- `articleNode`: `headline`/`name` = story title, `image` = cover,
`author`/`publisher` = Comfy Org, `isPartOf` + `mainEntityOfPage` linked
to the page's `WebPage` `@id`. `description`/`image` are dropped from
the markup when absent.
- zh-CN breadcrumbs point at `/zh-CN/` and `/zh-CN/customers/` (not the
English pages).
- The listing `ItemList` length is tied to the story cards actually
rendered, not a hardcoded count.

## Verification

typecheck 0 errors, unit 222/222 (incl. new `articleNode` tests), build
505 pages, `validate:jsonld` passed across 508 pages, customers e2e 8/8
(incl. the collection + Article graph assertions). Confirmed in the
built HTML across en/zh listing and detail pages: exactly one `ld+json`
block, one `Organization`, no broken `@id` references, and correct
per-locale language and crumbs.
2026-07-14 08:56:41 -04:00
Michael B
845d722a68 feat(website): rename education route from /edu to /education 2026-07-13 20:27:06 -04:00
github-actions
56b46a1c01 [automated] Update website screenshot expectations 2026-07-13 23:54:02 +00:00
Michael B
31c32af2fb Merge remote-tracking branch 'origin/main' into feat/edu-page
# Conflicts:
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png
#	apps/website/src/components/ui/badge/index.ts
2026-07-13 17:21:01 -04:00
Michael B
5c5f6afa6e fix(website): hydrate the education hero so reduced-motion guard runs
HeroSection had no client: directive, so HeroBackdrop01's
prefers-reduced-motion handling (autoplay binding + pause-on-mount ref)
never executed and the backdrop video autoplayed for everyone. Hydrate
with client:load on both locale pages, matching the other video-hero
pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:10:44 -04:00
Michael B
121e66e799 fix(website): vertically center pricing toggle labels
PP Formula's asymmetric vertical metrics make the billing-toggle text ride
high in its pill. Wrap each label in the shared ppformula-text-center span
(as Badge/Button already do) so the caps sit centered. Applied at the
PricingSection consumer to keep the vendored ToggleGroupItem primitive
close to upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:06:36 -04:00
Michael B
99ffa6ad0e test(website): align edu pricing e2e with current toggle label and CTA
The billing-toggle tests searched for "Monthly" but the edu page renders
"Monthly (Up to 15% off)" (pricing.period.monthly.edu), so the toggle was
never clicked and the tests timed out. The Contact Us CTA test still expected
/contact, but the edu contact band now opens the Creative Campus application
form in a new tab. Update both to match the shipped behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:36:05 -04:00
Michael B
df7652606c fix(website): balance section heading text wrapping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 14:06:14 -04:00
Michael B
0a45b1bbf0 feat(website): add customer stories subtitle and edu CTA link override
Adds subtitle prop to CustomerStories01 and wires up the education
subheading translation. Extends PricingContactBand with an optional href
prop so the education page can point the enterprise CTA to the Creative
Campus application form instead of the contact route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 12:26:25 -04:00
Michael B
b9a7e9fb6e fix(website): keep one billing cycle selected in pricing toggle
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:12:27 -04:00
Michael B
dffa3927e4 refactor(website): use function declaration for eduSavingsFeature
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:24:03 -04:00
Michael B
a18ae61929 feat(website): add Edu nav item and align education page copy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:50:56 -04:00
Michael B
782d9a270c Merge branch 'main' into feat/edu-page
# Conflicts:
#	apps/website/src/components/blocks/HeroBackdrop01.vue
#	apps/website/src/pages/cloud/pricing.astro
#	apps/website/src/pages/zh-CN/cloud/pricing.astro
2026-07-10 09:39:33 -04:00
Michael B
6533064bff Merge branch 'main' into feat/edu-page 2026-07-10 09:10:05 -04:00
Michael B
638db87e51 fix(website): align zh-CN creative campus copy with pricing subtitle
pricing.creativeCampus.description and pricing.subtitle.edu render the
same English sentence on /edu, but their zh-CN diverged (获得 vs 共同打造).
Align the Creative Campus band to 共同打造 for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:42:05 -04:00
Michael B
92b4b27d7f feat(website): add course accreditation to creative campus copy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:45:54 -04:00
Michael B
addd07b657 Git comments fixes 2026-07-08 14:39:42 -04:00
Michael B
bdb3f3b36a feat(website): highlighted edu savings row on pricing cards
Add an optional highlight variant to plan features, rendered with a
BookOpen icon and yellow text. In education mode, edu-priced plans lead
with a "Educational savings – 10% off" row via a new pure planFeatures()
helper (unit-tested). status stays inclusion-only so sr-only labels are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:00:21 -04:00
Michael B
58e31f90bf feat(website): mobile edu hero + pricing toggle polish
- HeroBackdrop01: stack backdrop above content on mobile via
  responsive absolute/relative; add optional mobileBackdrop asset
- edu HeroSection: use edu_hero-mobile.mp4 on mobile
- pricing toggle: shrink font/min-width on small screens to stop
  overflow; add --text-2xs token
- edu pricing: "Monthly (Up to 15% off)" toggle label and edu
  subtitle copy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:44:36 -04:00
Michael B
fcaa516e53 Merge remote-tracking branch 'origin/main' into feat/edu-page
# Conflicts:
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-2-md-visual-linux.png
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png
2026-07-08 11:52:58 -04:00
Michael B
dec06193c8 fix(website): address CodeRabbit review on /edu
- Fix dead #plans anchor: pass id="plans" to PricingSection on /edu and
  assert the target is attached in the e2e test
- Respect prefers-reduced-motion: gate autoplay on the HeroBackdrop01 and
  StepsSplit01 backdrop videos, and add a poster to the edu hero video
- Fix Slider active-tick detection: map value to nearest tick index instead
  of floating-point === (default team tier now highlights correctly)
- ScrollCarousel: type="button" on nav controls, aria-hidden on the
  decorative progress bar, and i18n keys for the prev/next aria-labels
- PricingSection: derive per-plan display values via a planCards computed,
  removing the duplicate displayPriceKey call and the non-null assertion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:35:49 -04:00
github-actions
1bade18e8a [automated] Update website screenshot expectations 2026-07-07 16:39:56 +00:00
Michael B
e062d26ab6 Merge remote-tracking branch 'origin/main' into feat/edu-page
# Conflicts:
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png
#	apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png
2026-07-07 12:18:16 -04:00
Michael B
124bf16b5f fix(website): improve Student Ambassador band layout on mobile
Keep the thin-band padding only on desktop (lg:py-3) so the stacked
mobile layout uses the normal card padding, switch inline-flex to flex
so it fills the card width, and constrain it to max-w-lg (matching the
pricing grid) so it aligns with the Creative Campus card above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:53:32 -04:00
Michael B
5a63c16b64 feat(website): add Creative Campus stories carousel to /edu
Extract the carousel shell from FeedbackSection into a reusable
ui/ScrollCarousel and the story card from StorySection into StoryCard,
then compose both in a CustomerStories01 block + education template.
The /edu pages load the Creative Campus stories (selected by slug so the
set is stable across locales) and render the section only when stories
exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:52:51 -04:00
Michael B
5ed7cca16b Round of refactor 2026-07-07 11:20:28 -04:00
Michael B
0d75907da5 feat(website): show discount text only on the team plan
Stop passing the education savings label to the standard, creator, and
pro cards; PricingPrice now renders the discount only when discount text
is provided (dropping the showDiscount prop and layout placeholder). Also
swap the Student Ambassador CTA to the design-system Button and apply
minor pricing band/label styling tweaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:50:31 -04:00
Mobeen Abdullah
2aefd12372 feat(website): add five Creative Campus customer stories (#13409)
## Summary

Adds the five Creative Campus (education) customer stories into the
education launch bundle (`feat/edu-page`), so pricing, the customer
stories, and the education page can go live together.

These stories were originally merged to `main` in #13370, then reverted
(#13407) because they went public before the education page was ready.
This re-lands the exact same reviewed work on top of the bundle branch
instead of `main`. Base is `feat/edu-page`, not `main`.

## Changes

- **What**: The 5 English MDX stories (Xindi Zhang, Ina Conradi, Golan
Levin, Kathy Smith, UAL CCI) plus the reusable article blocks they use
(`Embed`, `Video`, `Download`, `AuthorBio`, `EducationCta`, `AtAGlance`,
`Link`, `Heading4`), the `CustomerArticle` element map, the
`bg-site-bg-soft` theme utility, the article `max-w-9xl` cap, and the
customer content/e2e tests. Identical to the reverted #13370.
- **Breaking**: none. Existing stories and pages are untouched.
- **Dependencies**: none.

## Review Focus

- This is a clean cherry-pick of the squashed #13370; it applies with
zero conflicts on the current bundle.
- All prior review feedback (CodeRabbit + Michael + design) was already
addressed in #13370 before it was merged and reverted, so this content
is final.

## Verification

- Cherry-pick: zero conflicts. Build: 504 pages. Unit: 176/176.
Typecheck: 0 errors. format:check and knip clean.
- Nav featured card and both `/customers` locales verified in-browser
previously; unchanged here.

## Notes

- JSON-LD structured data for these pages will follow as a separate PR
onto this same bundle (superseding the on-hold #13404).

## Screenshots

**/customers listing**

_Desktop:_

_Mobile:_

**Story detail (example: Xindi Zhang)**

_Desktop:_

_Mobile:_

Preview: https://comfy-website-preview-pr-13409.vercel.app/customers
2026-07-06 18:39:39 -04:00
Michael B
46f4c28cc9 feat(website): add Student Ambassador interest band to /edu
Render a new full-width Student Ambassador band inside PricingSection in
education mode only, below the Creative Campus band. It shows a clock +
"Coming Soon" tag, a "Student Ambassador" label, a short description, and
an active "Register Interest" text-link that opens the external interest
form in a new tab. The band does not appear on /cloud/pricing.

Add externalLinks.studentAmbassadorForm, band copy keys (en + drafted
zh-CN), and extend education.spec.ts to assert the band and its CTA;
pricing.spec.ts guards the band's absence on /cloud/pricing.

Also harden the two billing-toggle e2e tests with a hydration-safe
switchToMonthly helper: the pricing island is client:visible, so a
toggle click could land before hydration and intermittently fail. The
helper retries the click (deselection-safe) until the target price shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:42:35 -04:00
Michael B
67a1a858de feat(website): re-skin enterprise band as Creative Campus on /edu
In education mode, PricingEnterpriseBand renders a "Creative Campus"
partnership label and description instead of the "Enterprise" copy. The
component, layout, Contact Us CTA, and /contact destination are shared
across both modes; only the label/description keys swap on the education
prop. /cloud/pricing stays "Enterprise".

Add pricing.creativeCampus.label and .description (en + drafted zh-CN),
thread the education prop from PricingSection, extend education.spec.ts
to assert the Creative Campus copy and Contact Us -> /contact CTA, and
guard in pricing.spec.ts that /cloud/pricing shows no Creative Campus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:57:39 -04:00
Michael B
5d17502e35 feat(website): add education pricing to the team plan card
Extend education mode to the Team plan card on /edu. When education is
set, the team card headline shows the tier's education price with the
monthly basePrice struck through, plus an inline
"Educational saving ~{pct}% ({amount})" label (singular form, distinct
from the individual cards' "– {pct}% off"). The price and label react to
both the credit-tier slider and the billing toggle.

Add eduMonthlyPrice/eduYearlyPrice to every TeamCreditTier (monthly
$190/$370/$630/$1,225/$2,125; yearly $190/$360/$595/$1,120/$1,875) and a
pricing.team.educationalSaving key (en + drafted zh-CN). The saving
percentage/amount reuse the existing one-decimal team-saving math.

Extend education.spec.ts to assert the team education price,
strikethrough, and inline saving label, including a slider tier change
and a billing-period change. /cloud/pricing stays in standard mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:22:44 -04:00
Michael B
cee60e55df feat(website): add education pricing mode to individual plan cards
Introduce an `education` prop on the shared PricingSection and render
education prices on the individual plan cards (Standard/Creator/Pro) on
/edu only. Cards show the education headline price with the monthly list
price struck through, an "Educational savings – {pct}% off" label that
flips 10% (monthly) to 25% (yearly) with the billing toggle, and a
"Yearly (Up to 25% off)" toggle label. /cloud/pricing passes nothing and
is unchanged.

Education prices are stored as explicit i18n keys (en + drafted zh-CN):
Standard $18/$15, Creator $31.50/$26.25, Pro $90/$75.

Extend education.spec.ts to assert the education prices, strikethrough,
savings labels, and 25%-off toggle; add a regression guard to
pricing.spec.ts proving /cloud/pricing stays in standard mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:01:32 -04:00
Michael B
e27b388420 feat(website): add full terms FAQ to education page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:54:54 -04:00
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
94 changed files with 5086 additions and 850 deletions

View File

@@ -115,8 +115,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

@@ -70,4 +70,58 @@ test.describe('Customer story detail @smoke', () => {
'/customers/series-entertainment'
)
})
test('renders a Creative Campus story with its education blocks', async ({
page
}) => {
await page.goto('/customers/xindi-zhang')
await expect(
page.getByRole('heading', {
level: 1,
name: /The tool that expands my art/i
})
).toBeVisible()
const nav = page.getByRole('navigation', { name: 'Category filter' })
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
await expect(nav.getByRole('button', { name: 'AT A GLANCE' })).toBeVisible()
// At a glance block (AtAGlance component) with its spec rows.
await expect(
page.getByRole('heading', { name: 'At a glance' })
).toBeVisible()
await expect(page.getByText('Program', { exact: true })).toBeVisible()
// Workflow download button (Download component).
await expect(
page.getByRole('link', {
name: /Download Xindi's style transfer workflow/i
})
).toBeVisible()
// Shared education call to action (EducationCta component).
await expect(
page.getByRole('link', { name: /Explore the Education Program/i })
).toBeVisible()
})
test('emits an Article JSON-LD graph for the story', async ({ page }) => {
await page.goto('/customers/series-entertainment')
const blocks = await page
.locator('script[type="application/ld+json"]')
.allTextContents()
expect(blocks).toHaveLength(1)
const graph = JSON.parse(blocks[0])['@graph'] as Record<string, unknown>[]
const types = graph.map((node) => node['@type'])
expect(types.filter((type) => type === 'Organization')).toHaveLength(1)
expect(types).toContain('WebPage')
expect(types).toContain('BreadcrumbList')
// The Article headline tracks the visible story title, not a fixed string.
const article = graph.find((node) => node['@type'] === 'Article')
expect(article?.headline).toMatch(/Series Entertainment/i)
})
})

View File

@@ -30,4 +30,37 @@ test.describe('Customers @smoke', () => {
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
test('emits one connected JSON-LD graph describing the story collection', async ({
page
}) => {
const blocks = await page
.locator('script[type="application/ld+json"]')
.allTextContents()
expect(blocks).toHaveLength(1)
const graph = JSON.parse(blocks[0])['@graph'] as Record<string, unknown>[]
const types = graph.map((node) => node['@type'])
expect(types.filter((type) => type === 'Organization')).toHaveLength(1)
expect(types).toContain('CollectionPage')
expect(types).toContain('BreadcrumbList')
// Tie the list length to the story cards actually rendered, so the test
// tracks real content rather than a hardcoded count.
const cardSlugs = await page
.locator('a[href^="/customers/"]')
.evaluateAll((links) => [
...new Set(
links
.map((link) => link.getAttribute('href'))
.filter((href): href is string =>
/^\/customers\/[a-z0-9-]+$/.test(href ?? '')
)
)
])
expect(cardSlugs.length).toBeGreaterThan(0)
const list = graph.find((node) => node['@type'] === 'ItemList')
expect(list?.itemListElement as unknown[]).toHaveLength(cardSlugs.length)
})
})

View File

@@ -0,0 +1,401 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { externalLinks } from '../src/config/routes'
import { educationFaqs } from '../src/data/educationFaq'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/education'
const LEARNING_PATH = '/learning'
const PRICING_PATH = '/cloud/pricing'
const STUDENT_AMBASSADOR_FORM = externalLinks.studentAmbassadorForm
const MONTHLY_LABEL = t('pricing.period.monthly.edu', 'en')
const EDU_YEARLY_TOGGLE = t('pricing.period.yearly.edu', 'en')
const eduTeamSaving = (pct: number, amount: string) =>
t('pricing.team.educationalSaving', 'en')
.replace('{pct}', String(pct))
.replace('{amount}', amount)
const pricingSection = (page: Page) =>
page.locator('section').filter({
has: page.getByRole('heading', { name: /Choose a plan/i })
})
// The pricing section is an Astro `client:visible` island, so the billing
// toggle only becomes interactive once it hydrates — a click can otherwise
// land before the handler is attached. Retry the click (only while the target
// state is absent, so a re-click can't deselect) until a sentinel price shows.
const switchToMonthly = async (page: Page, sentinel: Locator) => {
const monthly = page.getByText(MONTHLY_LABEL, { exact: true })
await expect(async () => {
if (!(await sentinel.isVisible())) await monthly.click()
await expect(sentinel).toBeVisible({ timeout: 1000 })
}).toPass({ timeout: 15_000 })
}
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, { exact: true })).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')
// The href is only useful if it resolves to a real target on the page.
await expect(page.locator('#plans')).toBeAttached()
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')
})
})
type JsonLdNode = Record<string, unknown>
// The page emits one connected @graph, so every node (FAQPage, Product,
// breadcrumbs) is read from that single ld+json block. Blocks are selected by
// parsed structure, not text matching, and the "exactly one" assumption is
// asserted rather than assumed.
const readJsonLdGraph = async (page: Page): Promise<JsonLdNode[]> => {
const graphs = await page.evaluate(() => {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]'
)
)
return scripts
.map((script) => {
try {
return JSON.parse(script.textContent ?? '') as unknown
} catch {
return null
}
})
.filter(
(parsed): parsed is { '@graph': unknown[] } =>
parsed !== null &&
Array.isArray((parsed as Record<string, unknown>)['@graph'])
)
})
expect(graphs, 'exactly one JSON-LD @graph block').toHaveLength(1)
return graphs[0]['@graph'] as JsonLdNode[]
}
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 graph = await readJsonLdGraph(page)
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node').toBeTruthy()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect((faqPage!.mainEntity as unknown[]).length).toBe(FAQ_COUNT)
})
test('describes the discounted plans as a priced Product', async ({
page
}) => {
const graph = await readJsonLdGraph(page)
const product = graph.find((node) => node['@type'] === 'Product')
const webPage = graph.find((node) => node['@type'] === 'WebPage')
expect(product, 'Product node').toBeTruthy()
expect(webPage?.mainEntity).toEqual({ '@id': product!['@id'] })
// The discounted education prices, not the list prices — a regression to
// pricingOffers would still pass a positive-USD check.
const offers = product!.offers as JsonLdNode[]
expect(offers.map((offer) => offer.price)).toEqual(['18', '31.50', '90'])
for (const offer of offers) {
expect(offer.priceCurrency).toBe('USD')
}
const breadcrumb = graph.find((node) => node['@type'] === 'BreadcrumbList')
expect(breadcrumb, 'BreadcrumbList node').toBeTruthy()
const names = (breadcrumb!.itemListElement as JsonLdNode[]).map(
(item) => item.name
)
expect(names).toEqual([
t('breadcrumb.home', 'en'),
t('nav.education', 'en')
])
})
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')
// The trigger renders aria-expanded="false" server-side, so a click can
// land before the island hydrates. Re-click until it actually toggles.
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()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
})
})
test.describe('Education pricing — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('shows yearly education prices with the monthly list price struck through', async ({
page
}) => {
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
// Default billing period is yearly: 25% off the monthly list price.
await expect(section.getByText('$15', { exact: true })).toBeVisible()
await expect(section.getByText('$26.25', { exact: true })).toBeVisible()
await expect(section.getByText('$75', { exact: true })).toBeVisible()
// The highlighted savings row tracks the yearly discount.
await expect(
section.getByText('Educational savings 25% off').first()
).toBeVisible()
for (const listPrice of ['$20', '$35', '$100']) {
await expect(
section.locator('span.line-through', {
hasText: new RegExp(`^\\${listPrice}$`)
})
).toBeVisible()
}
await expect(page.getByText(EDU_YEARLY_TOGGLE)).toBeVisible()
})
test('education prices flip with the billing toggle', async ({ page }) => {
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
// Flip to monthly: 10% off the monthly list price.
await switchToMonthly(page, section.getByText('$18', { exact: true }))
await expect(section.getByText('$31.50', { exact: true })).toBeVisible()
await expect(section.getByText('$90', { exact: true })).toBeVisible()
// The highlighted savings row now reflects the monthly discount.
await expect(
section.getByText('Educational savings 10% off').first()
).toBeVisible()
// Strikethrough remains the monthly list price in both cycles.
for (const listPrice of ['$20', '$35', '$100']) {
await expect(
section.locator('span.line-through', {
hasText: new RegExp(`^\\${listPrice}$`)
})
).toBeVisible()
}
})
})
test.describe('Education pricing — team card @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('shows the team education price with basePrice struck and a saving label', async ({
page
}) => {
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
// Default: yearly, default tier (basePrice $700) → 15% off → $595.
await expect(section.getByText('$595', { exact: true })).toBeVisible()
await expect(
section.locator('span.line-through', { hasText: /^\$700$/ })
).toBeVisible()
await expect(section.getByText(eduTeamSaving(15, '$105'))).toBeVisible()
})
test('slider tier change updates the team education price and saving', async ({
page
}) => {
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
const slider = section.getByRole('slider')
await slider.focus()
await page.keyboard.press('ArrowRight')
// Next tier (basePrice $1,400) yearly → 20% off → $1,120.
await expect(section.getByText('$1,120', { exact: true })).toBeVisible()
await expect(section.getByText(eduTeamSaving(20, '$280'))).toBeVisible()
})
test('billing toggle switches the team monthly/yearly education price', async ({
page
}) => {
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
// Monthly, default tier (basePrice $700) → 10% off → $630.
await switchToMonthly(page, section.getByText('$630', { exact: true }))
await expect(
section.locator('span.line-through', { hasText: /^\$700$/ })
).toBeVisible()
await expect(section.getByText(eduTeamSaving(10, '$70'))).toBeVisible()
})
})
test.describe('Education pricing — Creative Campus band @smoke', () => {
const CAMPUS_LABEL = t('pricing.creativeCampus.label', 'en')
const CAMPUS_DESC = t('pricing.creativeCampus.description', 'en')
const CONTACT_CTA = t('pricing.enterprise.cta', 'en')
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders the Creative Campus label and description', async ({
page
}) => {
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
await expect(section.getByText(CAMPUS_LABEL, { exact: true })).toBeVisible()
await expect(section.getByText(CAMPUS_DESC)).toBeVisible()
})
test('Contact Us CTA opens the Creative Campus application form', async ({
page
}) => {
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
const contact = section.getByRole('link', { name: CONTACT_CTA })
await expect(contact).toBeVisible()
await expect(contact).toHaveAttribute(
'href',
externalLinks.creativeCampusApplicationForm
)
await expect(contact).toHaveAttribute('target', '_blank')
})
})
test.describe('Education pricing — Student Ambassador band @smoke', () => {
const AMBASSADOR_LABEL = t('pricing.studentAmbassador.label', 'en')
const AMBASSADOR_TAG = t('pricing.studentAmbassador.comingSoon', 'en')
const AMBASSADOR_DESC = t('pricing.studentAmbassador.description', 'en')
const AMBASSADOR_CTA = t('pricing.studentAmbassador.cta', 'en')
test('renders the band with an active Register Interest CTA to the form', async ({
page
}) => {
await page.goto(PATH)
const section = pricingSection(page)
await section.scrollIntoViewIfNeeded()
await expect(
section.getByText(AMBASSADOR_LABEL, { exact: true })
).toBeVisible()
await expect(
section.getByText(AMBASSADOR_TAG, { exact: true })
).toBeVisible()
await expect(section.getByText(AMBASSADOR_DESC)).toBeVisible()
const register = section.getByRole('link', { name: AMBASSADOR_CTA })
await expect(register).toBeVisible()
await expect(register).toHaveAttribute('href', STUDENT_AMBASSADOR_FORM)
await expect(register).toHaveAttribute('target', '_blank')
})
})
test.describe('Education landing — zh-CN @smoke', () => {
test('CTA plan anchor resolves to the pricing section', async ({ page }) => {
await page.goto('/zh-CN/education')
const choosePlan = page.getByRole('link', {
name: t('education.cta.choosePlan', 'zh-CN')
})
await expect(choosePlan).toHaveAttribute('href', '#plans')
await expect(page.locator('#plans')).toBeAttached()
})
})
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,41 +8,40 @@ 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
)
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
})
test('stays in standard (non-education) mode', async ({ page }) => {
await expect(page.getByText(/Yearly \(Up to 20% off\)/)).toBeVisible()
await expect(page.getByText(/Yearly \(Up to 25% off\)/)).toHaveCount(0)
await expect(page.getByText(/Educational savings/i)).toHaveCount(0)
await expect(page.getByText(/Creative Campus/i)).toHaveCount(0)
await expect(page.getByText(/Student Ambassador/i)).toHaveCount(0)
})
})
test.describe('Cloud pricing teaser @smoke', () => {

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: 96 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { Locale } from '../../i18n/translations'
import type { StoryCard as StoryCardType } from '../../utils/customers'
import SectionHeader from '../common/SectionHeader.vue'
import StoryCard from '../customers/StoryCard.vue'
import ScrollCarousel from '../ui/scroll-carousel/ScrollCarousel.vue'
const {
stories,
heading,
subtitle,
locale = 'en',
class: className
} = defineProps<{
stories: StoryCardType[]
heading?: string
subtitle?: string
locale?: Locale
class?: HTMLAttributes['class']
}>()
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24', className)"
>
<SectionHeader v-if="heading" class="mb-12 lg:mb-16">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<!-- Reuse the shared carousel shell; neutralise its outer chrome so the
section wrapper above owns the padding and max-width. -->
<ScrollCarousel
:locale="locale"
gap-class="gap-6"
class="max-w-none p-0 lg:p-0"
>
<StoryCard
v-for="story in stories"
:key="story.slug"
:story="story"
:locale="locale"
class="w-[80%] shrink-0 snap-start sm:w-[70%] lg:w-[calc(50%-0.75rem)]"
/>
</ScrollCarousel>
</section>
</template>

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,97 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
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')
}
// Respect prefers-reduced-motion: don't autoplay the looping media video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
</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="!reduceMotion"
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

@@ -43,7 +43,7 @@ const headingSizeClass = {
:is="headingTag"
:class="
cn(
'text-primary-comfy-canvas',
'text-balance text-primary-comfy-canvas',
label && 'mt-4',
headingSizeClass[headingSize]
)

View File

@@ -4,16 +4,24 @@ import { render } from 'astro:content'
import type { Locale } from '../../i18n/translations'
import type { CustomerStoryEntry } from '../../utils/customers'
import ArticleNav from './ArticleNav.vue'
import AtAGlance from './content/AtAGlance.astro'
import AuthorBio from './content/AuthorBio.astro'
import BulletList from './content/BulletList.astro'
import Contributors from './content/Contributors.astro'
import Download from './content/Download.astro'
import EducationCta from './content/EducationCta.astro'
import Embed from './content/Embed.astro'
import Figure from './content/Figure.astro'
import Heading from './content/Heading.astro'
import Heading4 from './content/Heading4.astro'
import Link from './content/Link.astro'
import ListItem from './content/ListItem.astro'
import Paragraph from './content/Paragraph.astro'
import Quote from './content/Quote.astro'
import ReadMore from './content/ReadMore.vue'
import Section from './content/Section.astro'
import Steps from './content/Steps.astro'
import Video from './content/Video.astro'
interface Props {
entry: CustomerStoryEntry
@@ -34,18 +42,26 @@ const categories = entry.data.sections.map((section) => ({
// components (Section, Figure, ...) are used directly inside the MDX body.
const contentComponents = {
p: Paragraph,
a: Link,
h3: Heading,
h4: Heading4,
ul: BulletList,
li: ListItem,
Section,
Figure,
Quote,
Contributors,
Steps
Steps,
AtAGlance,
AuthorBio,
Download,
EducationCta,
Embed,
Video
}
---
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
<section class="max-w-9xl mx-auto px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
<div class="lg:flex lg:gap-16">
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
<div class="sticky top-32">

View File

@@ -1,9 +1,7 @@
<script setup lang="ts">
import { useScroll } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import ScrollCarousel from '../ui/scroll-carousel/ScrollCarousel.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -24,83 +22,26 @@ const feedbacks = [
role: 'customers.feedback.role3' as const
}
]
const trackRef = ref<HTMLElement>()
const { x } = useScroll(trackRef)
const progress = computed(() => {
const el = trackRef.value
if (!el) return 0
const max = el.scrollWidth - el.clientWidth
return max > 0 ? x.value / max : 0
})
function scroll(direction: -1 | 1) {
const el = trackRef.value
if (!el) return
el.scrollBy({ left: direction * el.clientWidth, behavior: 'smooth' })
}
const progressPercent = computed(() => `${progress.value * 100}%`)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<ScrollCarousel :locale="locale">
<div
ref="trackRef"
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
v-for="(fb, i) in feedbacks"
:key="i"
class="bg-transparency-white-t4 flex w-full shrink-0 snap-start flex-col justify-between rounded-3xl p-8 lg:w-3/4 lg:p-12"
>
<div
v-for="(fb, i) in feedbacks"
:key="i"
class="bg-transparency-white-t4 flex w-full shrink-0 snap-start flex-col justify-between rounded-3xl p-8 lg:w-3/4 lg:p-12"
>
<p class="text-primary-comfy-canvas text-2xl/relaxed font-light">
"{{ t(fb.quote, locale) }}"
<p class="text-2xl/relaxed font-light text-primary-comfy-canvas">
"{{ t(fb.quote, locale) }}"
</p>
<div class="mt-12">
<p class="text-primary-comfy-yellow text-base font-medium">
{{ t(fb.name, locale) }},
</p>
<p class="text-primary-comfy-yellow text-base font-medium">
{{ t(fb.role, locale) }}
</p>
<div class="mt-12">
<p class="text-primary-comfy-yellow text-base font-medium">
{{ t(fb.name, locale) }},
</p>
<p class="text-primary-comfy-yellow text-base font-medium">
{{ t(fb.role, locale) }}
</p>
</div>
</div>
</div>
<!-- Controls -->
<div class="mt-10 flex items-center gap-4">
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full"
:style="{ width: progressPercent }"
/>
</div>
<!-- Prev -->
<button
class="flex size-10 items-center justify-center rounded-full border border-white/20 text-white/60 transition-colors hover:border-white/40"
:aria-label="locale === 'zh-CN' ? '上一条' : 'Previous'"
@click="scroll(-1)"
>
<img
src="/icons/arrow-right.svg"
alt=""
class="size-3 rotate-180 opacity-60 invert"
/>
</button>
<!-- Next -->
<button
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-90"
:aria-label="locale === 'zh-CN' ? '下一条' : 'Next'"
@click="scroll(1)"
>
<img src="/icons/arrow-right.svg" alt="" class="size-3" />
</button>
</div>
</section>
</ScrollCarousel>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { StoryCard } from '../../utils/customers'
const { story, locale = 'en' } = defineProps<{
story: StoryCard
locale?: Locale
}>()
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
</script>
<template>
<a
:href="`${prefix}/customers/${story.slug}`"
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
>
<!-- Image -->
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
<div
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
:style="{ backgroundImage: `url(${story.cover})` }"
/>
</div>
<!-- Content -->
<div class="flex flex-1 flex-col justify-between px-6 pt-4 pb-6">
<div>
<span
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
>
{{ story.category }}
</span>
<h3
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
>
{{ story.title }}
</h3>
</div>
<div
class="mt-8 flex items-center gap-3 text-xs font-semibold tracking-widest uppercase"
>
<span
class="bg-primary-comfy-yellow flex size-8 items-center justify-center rounded-full"
>
<img src="/icons/arrow-right.svg" alt="" class="ml-0.5 size-3" />
</span>
<span class="text-primary-comfy-canvas">
{{ t('customers.story.viewArticle', locale) }}
</span>
</div>
</div>
</a>
</template>

View File

@@ -1,62 +1,23 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { StoryCard } from '../../utils/customers'
import type { StoryCard as StoryCardType } from '../../utils/customers'
import StoryCard from './StoryCard.vue'
const { stories, locale = 'en' } = defineProps<{
stories: StoryCard[]
stories: StoryCardType[]
locale?: Locale
}>()
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
</script>
<template>
<section
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
<StoryCard
v-for="story in stories"
:key="story.slug"
:href="`${prefix}/customers/${story.slug}`"
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
>
<!-- Image -->
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
<div
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
:style="{ backgroundImage: `url(${story.cover})` }"
/>
</div>
<!-- Content -->
<div class="flex flex-1 flex-col justify-between px-6 pt-4 pb-6">
<div>
<span
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
>
{{ story.category }}
</span>
<h3
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
>
{{ story.title }}
</h3>
</div>
<div
class="mt-8 flex items-center gap-3 text-xs font-semibold tracking-widest uppercase"
>
<span
class="bg-primary-comfy-yellow flex size-8 items-center justify-center rounded-full"
>
<img src="/icons/arrow-right.svg" alt="" class="ml-0.5 size-3" />
</span>
<span class="text-primary-comfy-canvas">
{{ t('customers.story.viewArticle', locale) }}
</span>
</div>
</div>
</a>
:story="story"
:locale="locale"
/>
</section>
</template>

View File

@@ -0,0 +1,29 @@
---
interface Row {
label: string
value: string
}
interface Props {
rows: Row[]
}
const { rows } = Astro.props
---
<div
class="my-8 overflow-hidden rounded-2xl border border-white/10 bg-site-bg-soft"
>
<dl class="divide-y divide-white/10">
{
rows.map((row) => (
<div class="flex flex-col gap-1 p-5 sm:flex-row sm:gap-6">
<dt class="text-primary-comfy-yellow shrink-0 text-xs font-bold tracking-widest uppercase sm:w-44">
{row.label}
</dt>
<dd class="text-sm/relaxed text-primary-comfy-canvas">{row.value}</dd>
</div>
))
}
</dl>
</div>

View File

@@ -0,0 +1,60 @@
---
interface Author {
name?: string
role?: string
photo?: string
bio?: string
}
interface Props {
label?: string
people: Author[]
}
const { label, people } = Astro.props
const hasBioSlot = Astro.slots.has('default')
---
<div class="mt-12 border-t border-white/10 pt-8">
{
label && (
<span class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase">
{label}
</span>
)
}
<div class="mt-4 space-y-8">
{
people.map((person) => (
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
{person.photo && (
<img
src={person.photo}
alt={person.name ?? ''}
class="size-20 shrink-0 rounded-full object-cover"
/>
)}
<div>
{person.name && (
<p class="text-sm font-semibold text-primary-comfy-canvas">
{person.name}
{person.role && (
<span class="text-primary-warm-gray"> · {person.role}</span>
)}
</p>
)}
{person.bio ? (
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
{person.bio}
</p>
) : hasBioSlot && people.length === 1 ? (
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
<slot />
</p>
) : null}
</div>
</div>
))
}
</div>
</div>

View File

@@ -14,7 +14,7 @@ interface Props {
const { label, people } = Astro.props
---
<div class="mt-8 rounded-2xl bg-(--site-bg-soft) p-6">
<div class="mt-8 rounded-2xl bg-site-bg-soft p-6">
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>

View File

@@ -0,0 +1,19 @@
---
interface Props {
href: string
label: string
newTab?: boolean
}
const { href, label, newTab = false } = Astro.props
---
<a
href={href}
download={newTab ? undefined : true}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
class="text-primary-comfy-yellow my-4 inline-block text-sm font-semibold underline underline-offset-2 transition-opacity hover:opacity-80"
>
{label}
</a>

View File

@@ -0,0 +1,23 @@
---
import { externalLinks, getRoutes } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import type { Locale } from '../../../i18n/translations'
import Link from './Link.astro'
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const routes = getRoutes(locale)
---
<div
class="border-primary-comfy-yellow mt-12 rounded-2xl border-l-4 bg-site-bg-soft p-8"
>
<p class="text-base/relaxed text-primary-comfy-canvas">
<strong class="font-semibold">{t('educationCta.heading', locale)}</strong>{' '}
{t('educationCta.body', locale)}{' '}
<Link href={routes.education}>{t('educationCta.exploreLink', locale)}</Link>{' '}
{t('educationCta.or', locale)}{' '}
<Link href={externalLinks.creativeCampusApplicationForm}>{t('educationCta.applyLink', locale)}</Link>{' '}
{t('educationCta.tail', locale)}
</p>
</div>

View File

@@ -0,0 +1,22 @@
---
interface Props {
src: string
title: string
}
const { src, title } = Astro.props
---
<div
class="my-8 aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black"
>
<iframe
src={src}
title={title}
class="size-full"
loading="lazy"
allow="autoplay; fullscreen; picture-in-picture; clipboard-write"
referrerpolicy="strict-origin-when-cross-origin"
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
></iframe>
</div>

View File

@@ -6,14 +6,15 @@ interface Props {
}
const { src, alt, caption } = Astro.props
const hasCaptionSlot = Astro.slots.has('default')
---
<figure class="my-8">
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
{
caption && (
(hasCaptionSlot || caption) && (
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
{caption}
{hasCaptionSlot ? <slot /> : caption}
</figcaption>
)
}

View File

@@ -0,0 +1,6 @@
---
---
<h4 class="mt-6 mb-2 text-base font-semibold text-primary-comfy-canvas">
<slot />
</h4>

View File

@@ -0,0 +1,15 @@
---
interface Props {
href: string
}
const { href } = Astro.props
const isExternal = /^https?:\/\//.test(href)
---
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
class="text-primary-comfy-yellow underline underline-offset-2 transition-opacity hover:opacity-80"
><slot /></a>

View File

@@ -1,16 +1,20 @@
---
interface Props {
name: string
name?: string
}
const { name } = Astro.props
---
<blockquote
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-(--site-bg-soft) p-8"
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-site-bg-soft p-8"
>
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
"<slot />"
</p>
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
{
name && (
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
)
}
</blockquote>

View File

@@ -0,0 +1,22 @@
---
import VideoPlayer from '../../common/VideoPlayer.vue'
interface Props {
src: string
poster?: string
caption?: string
}
const { src, poster, caption } = Astro.props
---
<figure class="my-8">
<VideoPlayer src={src} poster={poster} client:visible />
{
caption && (
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
{caption}
</figcaption>
)
}
</figure>

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,42 @@
<script setup lang="ts">
import type { Locale, TranslationKey } 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', href } = defineProps<{
labelKey: TranslationKey
descriptionKey: TranslationKey
locale?: Locale
href?: string
}>()
const ctaHref = computed(() => href ?? 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(labelKey, locale)" />
<p class="text-primary-warm-white">
{{ t(descriptionKey, locale) }}
</p>
</div>
<Button
:href="ctaHref"
:target="href ? '_blank' : undefined"
:rel="href ? 'noopener noreferrer' : undefined"
variant="outline"
>
{{ t('pricing.enterprise.cta', locale) }}
</Button>
</div>
</PricingCard>
</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

@@ -1,60 +1,90 @@
<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 { BookOpen, 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="
feature.highlight
? BookOpen
: statusIcon[feature.status ?? 'included']
"
class="mt-0.5 size-4 shrink-0"
:class="
feature.highlight
? 'text-primary-comfy-yellow'
: 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="
feature.highlight
? 'text-primary-comfy-yellow'
: 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 whitespace-nowrap uppercase',
className
)
"
>
{{ label }}
</span>
</template>

View File

@@ -0,0 +1,66 @@
<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
v-if="discount"
class="text-primary-comfy-yellow text-sm max-sm:text-xs sm:ml-2"
>
{{ 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,238 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { externalLinks } from '../../config/routes'
import { planFeatures, 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 PricingContactBand from './PricingContactBand.vue'
import PricingCredits from './PricingCredits.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingPlanLabel from './PricingPlanLabel.vue'
import PricingPrice from './PricingPrice.vue'
import PricingStudentAmbassadorBand from './PricingStudentAmbassadorBand.vue'
import PricingTeamCard from './PricingTeamCard.vue'
const {
locale = 'en',
education = false,
headingLevel = 'h1'
} = defineProps<{
locale?: Locale
education?: boolean
headingLevel?: 'h1' | 'h2'
}>()
const selectedBillingPeriod = ref<BillingCycle>('yearly')
// reka-ui's single toggle group emits undefined when the active item is
// clicked again; exactly one billing cycle must stay selected, so ignore it.
const billingPeriod = computed({
get: () => selectedBillingPeriod.value,
set: (value: BillingCycle | undefined) => {
if (value) {
selectedBillingPeriod.value = value
}
}
})
function displayPriceKey(plan: PricingPlan): TranslationKey | undefined {
if (education) {
// Plans without education pricing (e.g. free) fall back to the list price.
if (billingPeriod.value === 'yearly') {
return plan.eduYearlyPriceKey ?? plan.eduPriceKey ?? plan.priceKey
}
return plan.eduPriceKey ?? plan.priceKey
}
if (billingPeriod.value === 'yearly' && plan.yearlyPriceKey) {
return plan.yearlyPriceKey
}
return plan.priceKey
}
// In education mode the monthly list price is struck through in both cycles;
// otherwise only the yearly view strikes the (monthly) list price.
function originalPriceFor(plan: PricingPlan): string | undefined {
if (education) {
return plan.eduPriceKey && plan.priceKey
? t(plan.priceKey, locale)
: undefined
}
return billingPeriod.value === 'yearly' &&
plan.yearlyPriceKey &&
plan.priceKey
? t(plan.priceKey, locale)
: undefined
}
function yearlyTotalFor(plan: PricingPlan): string | undefined {
if (education) {
return plan.eduYearlyTotalKey
? t(plan.eduYearlyTotalKey, locale)
: undefined
}
return plan.yearlyTotalKey ? t(plan.yearlyTotalKey, locale) : undefined
}
// Derive each plan's display values once so the template doesn't call the
// helpers twice (and avoids the non-null assertion on the price key).
const planCards = computed(() =>
pricingPlans.map((plan) => ({
plan,
priceKey: displayPriceKey(plan),
originalPrice: originalPriceFor(plan),
yearlyTotal: yearlyTotalFor(plan),
features: planFeatures(plan, education, billingPeriod.value)
}))
)
</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">
<component
:is="headingLevel"
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</component>
<p
class="mx-auto mt-3 max-w-xl text-base text-pretty text-primary-comfy-canvas"
>
{{ t(education ? 'pricing.subtitle.edu' : '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-40 text-2xs sm:min-w-48 sm:text-xs"
>
<span class="ppformula-text-center">{{
t(
education
? 'pricing.period.monthly.edu'
: 'pricing.period.monthly',
locale
)
}}</span>
</ToggleGroupItem>
<ToggleGroupItem
value="yearly"
class="min-w-40 text-2xs sm:min-w-48 sm:text-xs"
>
<span class="ppformula-text-center">{{
t(
education ? 'pricing.period.yearly.edu' : 'pricing.period.yearly',
locale
)
}}</span>
</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,
priceKey,
originalPrice,
yearlyTotal,
features
} in planCards"
: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" size="xs">
{{ t('pricing.badge.popular', locale) }}</Badge
>
</div>
<!-- Price -->
<PricingPrice
v-if="priceKey"
:price="t(priceKey, locale)"
:period="t('pricing.plan.period', locale)"
:original-price="originalPrice"
:billing-period="billingPeriod"
:yearly-total="yearlyTotal"
:locale
/>
<!-- Features -->
<div v-if="features.length" class="mt-8">
<PricingPlanFeatureList :features="[{ 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" :education :locale />
<PricingContactBand
:label-key="
education
? 'pricing.creativeCampus.label'
: 'pricing.enterprise.label'
"
:description-key="
education
? 'pricing.creativeCampus.description'
: 'pricing.enterprise.description'
"
:href="
education ? externalLinks.creativeCampusApplicationForm : undefined
"
:locale
/>
</div>
<PricingStudentAmbassadorBand v-if="education" :locale />
<!-- Footnote -->
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
{{ t('pricing.footnote', locale) }}
</p>
</section>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { Clock } from '@lucide/vue'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
import Button from '../ui/button/Button.vue'
import PricingCard from './PricingCard.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
</script>
<template>
<PricingCard class="col-span-full max-lg:mx-auto max-lg:max-w-lg lg:py-3">
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
<span
class="text-primary-warm-gray inline-flex shrink-0 items-center gap-2 text-base tracking-wider uppercase"
>
<Clock class="size-5" />
<span class="ppformula-text-center">
{{ t('pricing.studentAmbassador.comingSoon', locale) }}
</span>
</span>
<span
class="ppformula-text-center shrink-0 text-base font-bold tracking-wider text-primary-comfy-canvas uppercase"
>
{{ t('pricing.studentAmbassador.label', locale) }}
</span>
<p class="text-primary-warm-white ppformula-text-center flex-1 text-base">
{{ t('pricing.studentAmbassador.description', locale) }}
</p>
<Button
:href="externalLinks.studentAmbassadorForm"
target="_blank"
rel="noopener noreferrer"
variant="underlineLink"
class="shrink-0"
>
{{ t('pricing.studentAmbassador.cta', locale) }}
</Button>
</div>
</PricingCard>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type { PlanFeatureGroup } from './PricingPlanFeatureList.vue'
import { computed, ref } from 'vue'
import { Component as ComponentIcon } from '@lucide/vue'
import { subscribeUrl } from '../../data/pricingPlans'
import {
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,
education = false
} = defineProps<{
billingPeriod: 'monthly' | 'yearly'
locale?: Locale
education?: boolean
}>()
const teamCreditTierIndex = ref<number[]>([2])
const selectedTeamTier = computed(
() => teamCreditTiers[teamCreditTierIndex.value[0] ?? 0]
)
const selectedTeamPrice = computed(() => {
const tier = selectedTeamTier.value
if (education) {
return billingPeriod === 'yearly'
? tier.eduYearlyPrice
: tier.eduMonthlyPrice
}
return billingPeriod === 'yearly' ? tier.yearlyPrice : tier.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(
education ? 'pricing.team.educationalSaving' : '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

@@ -14,7 +14,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 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 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 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,95 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useScroll } from '@vueuse/core'
import type { HTMLAttributes } from 'vue'
import { computed, ref } from 'vue'
import { t } from '../../../i18n/translations'
import type { Locale } from '../../../i18n/translations'
// Generic horizontal snap-scroll carousel: a scrollable track (default slot) with
// a progress bar and prev/next controls. Deliberately named ScrollCarousel rather
// than Carousel to avoid colliding with a future shadcn `Carousel`. Slotted items
// own their own width and `shrink-0 snap-start` classes.
const {
locale = 'en',
gapClass = 'gap-12 lg:gap-20',
class: className
} = defineProps<{
locale?: Locale
gapClass?: string
class?: HTMLAttributes['class']
}>()
const trackRef = ref<HTMLElement>()
const { x } = useScroll(trackRef)
const progress = computed(() => {
const el = trackRef.value
if (!el) return 0
const max = el.scrollWidth - el.clientWidth
return max > 0 ? x.value / max : 0
})
function scroll(direction: -1 | 1) {
const el = trackRef.value
if (!el) return
el.scrollBy({ left: direction * el.clientWidth, behavior: 'smooth' })
}
const progressPercent = computed(() => `${progress.value * 100}%`)
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24', className)"
>
<!-- Scrollable track -->
<div
ref="trackRef"
:class="
cn(
'flex snap-x snap-mandatory scrollbar-none overflow-x-auto',
gapClass
)
"
>
<slot />
</div>
<!-- Controls -->
<div class="mt-10 flex items-center gap-4">
<!-- Progress bar (decorative; navigation is via the buttons + scrollable track) -->
<div class="h-1 flex-1 rounded-full bg-white/20" aria-hidden="true">
<div
class="bg-primary-comfy-yellow h-full rounded-full"
:style="{ width: progressPercent }"
/>
</div>
<!-- Prev -->
<button
type="button"
class="flex size-10 items-center justify-center rounded-full border border-white/20 text-white/60 transition-colors hover:border-white/40"
:aria-label="t('carousel.previous', locale)"
@click="scroll(-1)"
>
<img
src="/icons/arrow-right.svg"
alt=""
class="size-3 rotate-180 opacity-60 invert"
/>
</button>
<!-- Next -->
<button
type="button"
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-90"
:aria-label="t('carousel.next', locale)"
@click="scroll(1)"
>
<img src="/icons/arrow-right.svg" alt="" class="size-3" />
</button>
</div>
</section>
</template>

View File

@@ -0,0 +1,119 @@
<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 {
const value = modelValue?.[0]
if (value == null || ticks == null || ticks <= 1) return false
// Map the current value to its nearest tick index and compare as integers,
// avoiding floating-point equality on the divide-then-multiply tick value
// (e.g. for 7 ticks, tickValue(2) === 0.9999999999999999, not 1).
const activeIndex =
max === min ? 0 : Math.round(((value - min) / (max - min)) * (ticks - 1))
return i - 1 === activeIndex
}
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

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { educationOffers, pricingOffers } from './pricing'
describe('educationOffers', () => {
it('marks up the discounted education prices, not the list prices', () => {
const edu = educationOffers('en')
const list = pricingOffers('en')
// Same plans in the same order, so a per-tier comparison is meaningful.
expect(edu.map((offer) => offer.name)).toEqual(
list.map((offer) => offer.name)
)
expect(edu.length).toBeGreaterThan(0)
edu.forEach((offer, index) => {
expect(offer.price).toMatch(/^\d+(\.\d+)?$/)
expect(Number(offer.price)).toBeLessThan(Number(list[index].price))
})
})
})

View File

@@ -6,23 +6,27 @@ interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
eduPriceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
priceKey: 'pricing.plan.standard.price',
eduPriceKey: 'pricing.plan.standard.eduPrice'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
priceKey: 'pricing.plan.creator.price',
eduPriceKey: 'pricing.plan.creator.eduPrice'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
priceKey: 'pricing.plan.pro.price',
eduPriceKey: 'pricing.plan.pro.eduPrice'
}
]
@@ -32,13 +36,17 @@ export interface PricingOffer {
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
// Shared offer builder: the pricing page marks up list prices, the education
// page marks up the discounted student/educator prices. Only plain USD amounts
// become offers so the JSON-LD Offer always carries a concrete numeric price.
function offersFrom(locale: Locale, education: boolean): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const priceKey = education ? tier.eduPriceKey : tier.priceKey
const display = t(priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
`${education ? 'educationOffers' : 'pricingOffers'}: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
@@ -51,3 +59,11 @@ export function pricingOffers(locale: Locale): PricingOffer[] {
]
})
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return offersFrom(locale, false)
}
export function educationOffers(locale: Locale): PricingOffer[] {
return offersFrom(locale, true)
}

View File

@@ -14,6 +14,7 @@ const baseRoutes = {
customers: '/customers',
demos: '/demos',
learning: '/learning',
education: '/education',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',
@@ -77,6 +78,7 @@ export const externalLinks = {
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
cloudStatus: 'https://status.comfy.org',
creativeCampusApplicationForm: 'https://tally.so/r/Xx97lL',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
@@ -91,6 +93,8 @@ export const externalLinks = {
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
studentAmbassadorForm:
'https://docs.google.com/forms/d/e/1FAIpQLScY3Ui44Mgho-4OQ1UAH3JLc3LCkY5kHDkHY7EZI6etTkVpZQ/viewform',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',

View File

@@ -63,8 +63,12 @@ function bodySectionIds(body: string): string[] {
const stories = loadStories()
it('finds all ten customer stories', () => {
expect(stories).toHaveLength(10)
it('finds customer stories in every locale', () => {
for (const locale of locales) {
const prefix = `${locale}/`
const inLocale = stories.filter((story) => story.file.startsWith(prefix))
expect(inLocale.length).toBeGreaterThan(0)
}
})
describe.for(stories)('$file', ({ frontmatter, body }) => {

View File

@@ -0,0 +1,148 @@
---
title: "Seeing the world in new ways: how Prof. Golan Levin teaches with ComfyUI at Carnegie Mellon University"
category: "CREATIVE CAMPUS SHOWCASE"
description: "\"For me, ComfyUI is not just about generative AI. It's an image-processing workstation for completely new kinds of work.\""
cover: "https://media.comfy.org/website/customers/golan-levin/cover.png"
order: 7
sections:
- id: topic-1
label: "INTRO"
- id: topic-2
label: "WHERE COMFYUI FITS"
- id: topic-3
label: "IMAGE SYNTHESIS"
- id: topic-4
label: "IMAGE ANALYSIS"
- id: topic-5
label: "THE CV LAB"
- id: topic-6
label: "AT A GLANCE"
- id: topic-7
label: "STUDENT WORK"
---
<Section id="topic-1">
<Figure src="https://media.comfy.org/website/customers/golan-levin/augmented-hand.jpg" alt="Golan Levin, Augmented Hand Series" caption="Golan Levin, Augmented Hand Series (2014), with Chris Sugrue and Kyle McDonald. Photo: Gerlinde de Geus, courtesy Cinekid." />
For many people, AI in the arts means image generation. But Levin has spent much of the past two decades teaching artists how computers can interpret, analyze, and measure the visual world. His own artworks have long explored machine perception through real-time computer vision systems, and since 2024 he has increasingly used ComfyUI to teach these principles.
For Levin, ComfyUI is less an image generator than an image-processing workbench. Students use it to assemble custom workflows for segmentation, tracking, depth estimation, and other forms of computational perception. The result is an environment where artists can experiment directly with research-grade machine learning tools and combine them into systems of their own design.
</Section>
<Section id="topic-2">
### Where does ComfyUI fit in what you're trying to do?
I'm training creative technologists and technologically literate artists. The typical student in my Creative Coding class is a true hybrid: an art or design undergraduate who is also studying computer science, human-computer interaction, or information science. They have strong visual abilities, strong cultural literacy, and strong algorithmic thinking skills, but my course may be the first time they've had the opportunity to bring those together.
To me, that means giving students tools they can understand, modify, and remix to make systems of their own design, rather than treating creative software as a fixed given. That's why I'm such a proponent of community-driven, open-source software development toolkits for the arts.
<Quote>ComfyUI is the first AI tool I've found with both a low floor and a high ceiling. It's incredibly powerful and flexible, in terms of allowing artists to design their own AI workflows with the latest cutting-edge algorithms. But it also leapfrogs the headaches of coping with quirky GitHub repos and obsolete Colab notebooks.</Quote>
### What were students stuck on before?
Students often found themselves caught between two worlds. On one side were commercial AI tools that produced impressive results but offered limited opportunities for customization. On the other side were research projects published by universities and laboratories, where the software was often difficult to install, poorly documented, or already out of date.
ComfyUI bridges that gap. It gives students access to state-of-the-art algorithms through an environment they can understand, modify, and extend. Instead of adapting their ideas to fit a tool's built-in workflow, they can build workflows that reflect their own interests and questions.
<Quote>My students are explorers. They're artists who can write code and want to build systems that haven't existed before.</Quote>
</Section>
<Section id="topic-3">
### The first exercise: a p5.js sketch driving image synthesis, inside ComfyUI
In one of Levin's introductory exercises — students' first exposure to the ComfyUI environment — they write a simple p5.js sketch directly inside ComfyUI, then use the shapes they draw, plus a text prompt, to guide a Stable Diffusion image synthesis. They document the pairs of images it produces: their JavaScript canvas drawing on the left, and the AI synthesis on the right. Having already spent a few weeks fighting to get nuance out of p5.js, they're tickled to get these results from simple shapes, and they learn a lot about how Stable Diffusion works.
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-landscape.png" alt="p5.js ellipses guiding a Stable Diffusion synthesis" caption={`Some wide ellipses drawn in p5.js (left) guiding a Stable Diffusion synthesis with the prompt "rolling hills, foggy day" (right).`} />
It runs on a node-based canvas that art students pick up quickly, because it works like tools they already know.
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-workflow.png" alt="Template ComfyUI workflow using the ComfyUI-p5js-node" caption="The template ComfyUI workflow students receive. It uses the custom ComfyUI-p5js-node by Ben Fox. From Levin's 60-212 course repo." />
*Try it yourself: [json file](https://media.comfy.org/website/customers/golan-levin/p5-in-comfy.json) (Comfy Local only)*
</Section>
<Section id="topic-4">
### Many artists start off by using ComfyUI for generative AI. You use it differently.
Maybe so. I'm interested in AI as a framework for expanded perception, so a lot of how I've used machine learning and computer vision over the past 25 years has been for image analysis, rather than image synthesis. Essentially, I use computer vision to understand video and images, and then use the information I extract to create new kinds of interactive experiences. In the classroom, I use ComfyUI to help teach students how to "see like a machine." So I have students use ComfyUI as a framework for analyzing images, not just generating them. For example, I ask them to take an input image and then use AI to compute new ones from it, such as a semantic segmentation ("which pixels belong to the elephant?") and a monocular depth estimate ("how far away is each pixel?"). Then the students build an interactive piece that interprets the original image, but using five channels of information instead of three: the usual red, green, and blue, plus depth, plus segmentation. In my demo project, the segmentation colors the elephant pink, and the background pixels change size based on how far away the AI thinks they are.
<Figure src="https://media.comfy.org/website/customers/golan-levin/depth-segmentation.png" alt="Semantic segmentation and monocular depth analysis in ComfyUI" caption={`An input image analyzed inside ComfyUI: semantic segmentation and monocular depth, feeding a five-channel "Custom Pixel" exercise. From Levin's 60-212 course repo.`} />
*Try it yourself: [demo project](https://editor.p5js.org/golan/sketches/-_cFmLtoP) · [lesson plan & workflow](https://github.com/golanlevin/60-212/tree/main/lectures/comfy/image_analysis#3-segment-the-image-with-ai)*
*Workflow files: download the [.json](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.json), or the [.png with the workflow embedded in its metadata](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.png) (drag it into ComfyUI to load the graph).*
<Quote>I want students to understand that AI is not only a tool for generating images. It's also a tool for perception, measurement, and analysis.</Quote>
The computer vision tools built for this are usually aimed at developers and enterprises. They assume an engineering workflow. I wanted my art students to get to segmentation, depth, and tracking inside an environment they already think in, without standing up a production pipeline first.
### What changed once ComfyUI was in the workflow?
Two things. First, it runs on a node-based canvas that many art students already understand from environments like TouchDesigner, Max/MSP, and Grasshopper — except it runs in a browser and it's for AI. As a result, students can focus on the ideas behind machine learning workflows instead of first learning an entirely new interaction paradigm. Second, it collapses the distance between a research lab and a classroom.
<Quote>There's a fast pipeline from the lab to your classroom. It's become commonplace for enthusiasts to convert AI research code into Comfy nodes, often within days of their release.</Quote>
One of the most remarkable things about the ComfyUI ecosystem is how quickly new research becomes accessible. A computer-vision paper might appear at CVPR or ICCV, and within days someone in the community has wrapped it as a reusable ComfyUI node. For educators, that dramatically shortens the distance between a research laboratory and a classroom. Instead of spending weeks reconstructing an experimental software environment, students can begin exploring the underlying ideas almost immediately.
The cloud matters for accessibility and equity, too. Most of my students don't have big GPU workstations, and I don't want their access to advanced tools to depend on the caliber of their personal hardware. Cloud platforms make it possible for everyone in a class to work in the same environment, with the same models, regardless of what laptop they happen to own.
</Section>
<Section id="topic-5">
### In your advanced Experimental Capture studio, you've turned ComfyUI into a computer-vision lab.
The goal of this course is to use technologies to help us see the world in new ways: the very fast, the very slow, the very small, the very large, and in spectra beyond human perception, like IR and UV. It's about cultivating the students' curiosity. But the limitation in this studio is hardware. We have one camera that can shoot 100,000 frames per second, one high-resolution thermal camera, and access to one electron microscope — but we've got 20 students. We can't always queue them all up for one exotic camera; it's a bottleneck.
<Quote>I need to give them tools they can use to see the world in new ways, that they can all run on their own hardware.</Quote>
ComfyUI allows students to use their own phones to ask questions they couldn't before. So they duct-tape their phone camera to a window, record the world going by, and then track things with the LocateAnything and SAM3 ComfyUI nodes, producing data files that distill what the camera saw. ComfyUI becomes a laboratory for computational observation, allowing students to ask questions of images and videos that would otherwise be difficult to formulate.
### You also wrap niche research libraries into ComfyUI nodes yourself.
One of the remarkable things about the ComfyUI ecosystem is the community that forms around it. There's a hero of mine on GitHub, Kijai, who keeps taking libraries from computer vision labs and turning them into ComfyUI nodes. He's made hundreds, probably doing more than anyone to turn lab-grade models into tools anyone can use. My students and I are starting to do this too. Niche is the right word. Right now I have my eye on a zoology lab that released a good library for tracking insect legs. The people who made it probably don't even know what ComfyUI is. But I want that algorithm for my students, and there's gotta be someone else out there who would love it too.
### What's the bigger pattern you see in your students?
My students are explorers. They see a new tool and immediately start wondering what else it could be connected to. They explore: I should be able to combine this thing with that other thing. That's the whole reason to give them a system they can build on, instead of a tool that tells them what they're allowed to do.
<Quote>We're educating students who want to invent new forms and experiences, not just reproduce existing ones.</Quote>
</Section>
<Section id="topic-6" title="At a glance">
<AtAGlance rows={[
{ label: "Courses", value: "Intermediate Studio: Creative Coding (60-212); Experimental Capture (co-taught with Nica Ross)" },
{ label: "Level", value: "Undergraduate (sophomore studio + advanced studio, ~20 students)" },
{ label: "Setup", value: "Cloud-hosted ComfyUI; runs on students' own laptops" },
{ label: "Core techniques", value: "p5.js-driven synthesis; semantic segmentation; monocular depth; LocateAnything + SAM3 tracking" },
{ label: "Distinctive angle", value: "ComfyUI as computer-vision lab, not just a generator" }
]} />
</Section>
<Section id="topic-7" title="Student work">
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-tippi.png" alt="Student work by Tippi Li" caption={`"nuclear explosion" by Tippi Li`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-xiao.png" alt="Student work by Xiao Yuan" caption={`"Chinese painting, plants, ink, transparent" by Xiao Yuan`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-aarnav.png" alt="Student work by Aarnav Patel" caption={`"NASA space image of a new cosmos detected" by Aarnav Patel`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-jeffrey.png" alt="Student work by Jeffrey Wang" caption={`"Dream Scene Painting" by Jeffrey Wang`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-kai.gif" alt="Student work by Kai Okorodudu" caption={`"Electric hand" by Kai Okorodudu`} />
</Section>
<AuthorBio people={[{ name: "Golan Levin", photo: "https://media.comfy.org/website/customers/golan-levin/author-golan.png" }]}>Golan Levin is a Professor of Computational Art at Carnegie Mellon University and co-author, with Tega Brain, of "Code as Creative Medium." This fall he is teaching two CMU courses with ComfyUI: "Intermediate Studio: Creative Coding" (60-212), built around p5.js, and "Experimental Capture," a studio in computational and expanded photography he co-teaches with Nica Ross. Levin is also widely known for interactive art installations driven by real-time machine vision, such as his [Augmented Hand Series](https://flong.com/archive/projects/augmented-hand-series/index.html) (2014), created with Kyle McDonald and Christine Sugrue.</AuthorBio>
<EducationCta />

View File

@@ -0,0 +1,149 @@
---
title: "From Node Graph to Building Façade: how Ina Conradi's NTU students compose architectural-scale public art with ComfyUI"
category: "CREATIVE CAMPUS SHOWCASE"
description: "At NTU in Singapore, Ina Conradi's students compose 90-second films for building-sized LED walls that prompt boxes cannot render but ComfyUI can, work that travels from campus to Hangzhou's West Lake Media Façade and a million viewers a day."
cover: "https://media.comfy.org/website/customers/ina-conradi/cover.png"
order: 6
sections:
- id: topic-1
label: "INTRO"
- id: topic-2
label: "THE CANVAS"
- id: topic-3
label: "WHY COMFYUI"
- id: topic-4
label: "THE 2026 BRIEF"
- id: topic-5
label: "STUDENT WORK"
- id: topic-6
label: "PUBLIC SCREENS"
- id: topic-7
label: "AT A GLANCE"
---
<Section id="topic-1">
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig1-quantum-logos.jpg" alt="Quantum Logos (Vision Serpent) on the Media Art Nexus LED screen" caption="Quantum Logos (Vision Serpent), Mark Chavez and Ina Conradi. Experimental animation, Media Art Nexus LED screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
### Building an AI art pipeline from studio to screen
Ina Conradi has written and taught NTU's two AI courses since 2022: DM2012, Explorations in AI-Generated Art (undergraduate), and AP7055, Art in the Age of the Creative Machine (postgraduate). Each runs about 30 students a semester. Working alongside her on the production pipeline is Mark Chavez, an animation veteran (DreamWorks, Rhythm & Hues) and early ComfyUI adopter. Together they co-curate the platform those courses build for: a 15-metre by 2-metre LED wall installed at NTU's North Spine in 2016 as Media Art Nexus, now run by NTU Museum as NTU Index and still taking new work each semester.
Work from the wall has travelled to giant public screens in Singapore (Ten Square), Hangzhou, and Chongqing, and into collaborations with Bauhaus University, the University of the Arts Berlin, and the Elbphilharmonie in Hamburg.
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig2-nature-sanctuary.jpg" alt="Nature Sanctuary 3000 on the West Lake Media Façade" caption="Nature Sanctuary 3000, Sowmya Sreeshna. Experimental animation, West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art." />
</Section>
<Section id="topic-2">
### Ina, your students don't make films for laptops. Why screens the size of buildings?
Because the format teaches. A 90-second film at 6K across, in an 8:1 panorama, cannot be a lucky prompt. It has to be composed. And the screens are real: the strongest student work plays on NTU Index, our 15-metre by 2-metre wall on campus, and travels to urban façades in China and Europe through the City Digital Skin Art Festival (CDSA). When a student knows a million people a day might walk past their film in Hangzhou, the conversation about craft changes.
### Mark, describe the canvas.
Basically, we do compositions for really large media LED screens in Singapore and China. We have a screen that's eight by one in Singapore. It's 5,888 by 768 pixels.Students create images in the class, usually about 6K resolution across, a long landscape panorama. The output is 90-second short films. Two minutes, 90 seconds. I'm not going to change. I love that format because it's manageable within the class.
</Section>
<Section id="topic-3">
### That format breaks most AI tools. What happened?
Runway is one of the tools we use, on an educational plan that has worked well for the school. The constraint we hit is format: Runway works in 16:9, and our 6K panoramas fall outside that. Last semester Midjourney gave us trouble at our resolution, and the upscale was difficult. So we're expanding the palette and bringing in ComfyUI alongside what we already run.
<Quote>ComfyUI gave the cleanest results. Upscaling to 8K at a 1-by-8 panorama after composition is genuinely hard, and ComfyUI is the only pipeline that lets students compose image, motion, and upscale models together.</Quote>
### What about the budget side?
Budget will keep being an issue. The school supports us well, but new tools arrive every semester and students want to try them and build their own pipelines. Monthly per-seat licenses don't fit how a semester runs. Running ComfyUI locally is hard for students: most laptops don't have a GPU with enough VRAM, and getting it working takes real trial and error. Many would rather work from home, but the hardware blocks them, so they come into the lab. Others used Comfy Cloud. It charges a subscription, but it still cost significantly less than the prepaid tools, and the results were better. Either way they're chasing the same thing: a pipeline they can keep working on, wherever they are.
### Ina, you insist these courses are not about tools. What are they about?
My class isnt about teaching a single tool. It is the responsive system students interact with across platforms, directing, critiquing, and shaping outputs through ongoing dialogue. ComfyUI fits this: a node graph is an argument you can read, question, and rebuild. A prompt box is not. Singaporean students become technically fluent very fast. What they need from arts education is the language to question what they're making, not just the skill to make it.
</Section>
<Section id="topic-4">
### Ina, the 2026 brief sends students to the ocean. What's the assignment?
The project is The Liquid Commons: Bringing Ocean Science into Global Media Architecture, developed in dialogue with OceanX, the organization behind the OceanXplorer research vessel, and the CDSA 2026 festival theme. The brief is strict: do not illustrate the science, translate it. The 2026 cohort is the first to build these films in ComfyUI with Topaz upscaling, working towards two real deadlines at once. Their pieces are in consideration for the OceanX Summit in Singapore this October, and jury-selected works will screen during the City Digital Skin Art Festival on Hangzhou's West Lake Media Façade: 170 metres by 18 metres, around a million viewers a day.
<Quote>The delivery spec tells you why the tooling matters: final exports at 5,888 × 768 px, 8K where required. That's the brief no prompt box can fill.</Quote>
</Section>
<Section id="topic-5">
### Mark, what does the student work look like?
About eight students have built their films through Comfy so far, and they're all pretty cool. They're surprising and insightful, because they're not limited by game-engine graphics. One student was the standout: he tried every model in Comfy and pushed the furthest.
Three projects from the 2026 cohort show the range.
**The Tao of Water** (Wang Zilin, AP7055) reads the ocean through the Tao Te Ching, a three-part arc from water to marine plant to void and back to origin. The pipeline moves from Pinterest research boards through Midjourney into ComfyUI, where Nano Banana extends single frames into seamless panoramas and Kling 3.0 animates first-frame-to-last-frame motion at full 5,888-pixel width, before a Premiere edit.
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig3-tao-of-water.jpg" alt="The Tao of Water on the NTU Index screen" caption="The Tao of Water, Wang Zilin. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
**microscophony** (Jiin Ko, AP7055) fuses *microscopic* and *micropolyphony*, Ligeti's term for dense webs of voices that blur into a single cloud of sound. The source is based on OceanX microscope footage of deep-sea microbes, translated into the visual logic of graphic notation (Ligeti, Xenakis, Cardew) so the panorama becomes a listening score. Images ran through Midjourney and Nano Banana, video through ComfyUI with Vidu Q2, sound design in Ableton Live, with distinct sonic textures mapped to distinct visual forms.
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig4-microscophony.jpg" alt="microscophony on the NTU Index screen" caption="microscophony, Jiin Ko. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
**GO! PLASTIC** (Jianwei Hoe, DM2012) is an ocean-plastics piece whose production log reads like studio paperwork, not prompt history. It opens with a one-line art direction (every project states its idea in a single line, with embedded irony, before a frame is generated), then walks through model selection, a platform-versus-local cost comparison (cost per clip and per scene on an RTX 5090 against a cloud B200, render times included), and a shot-by-shot sheet pairing every source image with its full prompt and settings.
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig5-go-plastic.jpg" alt="GO! PLASTIC on the NTU Index screen" caption="GO! PLASTIC, Hoe Jianwei. Experimental animation, NTU Index screen (15 m × 2 m), Singapore. Photo: Quek Jia Liang." />
</Section>
<Section id="topic-6">
### Ina, where does the work go after the classroom?
Onto public screens, and into juried international competition. The City Digital Skin Art Festival was established in 2023, initiated by the China Academy of Art's School of Sculpture and Public Art and co-curated with Public Art Lab Berlin, MEET Digital Culture Center Milan, and NTU ADM, with a network of more than 29 art academies across China and Europe.
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig6-cdsa-awards.jpg" alt="CDSA Festival award winners on the West Lake Media Façade" caption="CDSA Festival award winners, curators, and organizers. West Lake Media Façade (170 m × 18 m), Hangzhou, China. Photo: Limpid Art. Asia's largest high-definition outdoor screen" />
The 2024 edition ran across 11 LED screens in 9 cities in 5 countries and reached over 100 million views. The 20252026 edition, themed Memory Coexistence, drew over 200 international submissions, with the top 40 selected by a 16-member jury. I curate the Singapore programme across NTU Index and the Ten Square landmark façade. A student composing at 6K in our classroom is composing for that circuit.
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig7-crispr.jpg" alt="Crispr on the Ten Square Landmark Façade" caption="Crispr, Lee Chaewon. Experimental animation, Ten Square Landmark Façade (21.2 m × 14.4 m), Singapore. Photo: Quek Jia Liang." />
NTU ADM students have already won at this level. At CDSA 2025, the majority of the top awards went to students from these two courses: Gold (Sun Yutong, *Echoes of Her*), Silver (Tan Yu Yan Cheerie, *Eternal Flux*), Bronze (Shah Pranjal Kirti, *Mumbai Miniatures*), Business (Ong Sze Ching, *Nuwa*), and Creative (Leah Chakola, *Caravan of Memory*). The courses have also taken NTU to Ars Electronica in Linz as the only Singapore campus partner since 2023, first with *Butterfly's Dreams* (2023, "Who Owns the Truth?") and then in 2025 with *Beyond the Screen*, a joint exhibition with the China Academy of Art and Bauhaus-Universität Weimar.
### Mark, you spent a decade at DreamWorks. Why does this tool fit art students?
I come from visual effects. I was at DreamWorks about ten years, then Rhythm & Hues, then the game industry and big interactive installations. I'm not a programmer, so I love ComfyUI.
<Quote>Everybody I know who does graphics now is using this, because it's so adaptable. Sometimes we use Comfy as just a back end. That's what everybody's doing.</Quote>
We got this large 15-metre by 2-metre screen in an art installation at the university, and it let us explore media and different techniques. We found students weren't technical enough to handle TouchDesigner, so they just started making movies. Then I started playing with AI, and now everything's AI. What I'd love next is templates custom-made for these screens.
Take *Echoes, Whispers and Memories*, the piece Ina and I made. We don't use Comfy to spit out finished illustrations. We build workflows that keep recomposing the image, breaking it apart and putting it back together so it evolves on screen, which is the whole point: entropy, memory, things falling apart and reforming. Then we push those outputs into real-time and projection systems for big rooms, places like Ars Electronica's Deep Space 8K and MEET in Milan.
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig8-echoes.jpg" alt="Echoes, Whispers and Memories at Ars Electronica Deep Space 8K" caption="Echoes, Whispers and Memories, Mark Chavez and Ina Conradi. AI-generated immersive installation using ComfyUI, Deep Space 8K, Ars Electronica, Linz, Austria. Photo: Wolfgang Simlinger." />
### The signal from the industry
<Quote>I hear from my students looking for internships or jobs that the first question over there is, "Do you know Comfy?" Because they want to hire kids who know the pipeline.</Quote>
</Section>
<Section id="topic-7" title="At a glance">
<AtAGlance rows={[
{ label: "Institution", value: "Nanyang Technological University, School of Art, Design and Media (Singapore)" },
{ label: "Courses", value: "DM2012: Explorations in AI-Generated Art (UG) and AP7055: Art in the Age of the Creative Machine (PG), written and taught by Ina Conradi since 2022; ~30 students/semester" },
{ label: "The canvas", value: "6K-wide, 8:1 LED walls in Singapore and China; NTU Index wall on campus (15 m × 2 m, 5,888 × 768 px)" },
{ label: "Core technique", value: "ComfyUI compositions with Topaz upscaling for ultra-wide panoramic output; production logs with per-clip cost and prompt sheets" },
{ label: "Why Comfy won", value: "Hosted tools locked to 16:9; upscaling to 8K at a 1-by-8 panorama after composition needed a multi-model pipeline; per-seat monthly renewals didn't fit the semester" }
]} />
</Section>
<AuthorBio label="About the authors" people={[
{ name: "Ina Conradi", photo: "https://media.comfy.org/website/customers/ina-conradi/author-ina.jpg", bio: `Ina Conradi is an artist and curator based between Singapore and Los Angeles. She is founding faculty at NTU's School of Art, Design and Media (est. 2005), where she has written and taught the school's AI courses since 2022. Her film Moirai: Thread of Life won Best in Show at the SIGGRAPH Asia Computer Animation Festival 2023, a first for Singapore.` },
{ name: "Mark Chavez", photo: "https://media.comfy.org/website/customers/ina-conradi/author-mark.jpg", bio: `Mark Chavez is an animator, director, and founding faculty at NTU's School of Art, Design and Media in Singapore. After a decade at DreamWorks Animation and visual effects work at the original Rhythm & Hues Studios, he established NTU's Digital Animation area (2005) and an animation research think-tank funded by Singapore's National Research Foundation and the Media Development Authority.` }
]} />
<EducationCta />

View File

@@ -0,0 +1,138 @@
---
title: "Built for AI: Prof. Kathy Smith on USC's Expanded Animation program and ComfyUI"
category: "CREATIVE CAMPUS SHOWCASE"
description: "Inside the experimental USC MFA that put AI into animation pedagogy from day one, and the student pipelines it produced."
cover: "https://media.comfy.org/website/customers/kathy-smith/cover.png"
order: 8
sections:
- id: topic-1
label: "THE PROGRAM"
- id: topic-2
label: "TEACHING WITH AI"
- id: topic-3
label: "WHY COMFYUI"
- id: topic-4
label: "STUDENT WORK"
- id: topic-5
label: "AT A GLANCE"
- id: topic-6
label: "WHAT'S NEXT"
---
<Section id="topic-1">
### You built the Expanded Animation program in 2022 specifically to put AI into the curriculum from day one. What did you see that other programs missed?
We created Expanded Animation: Research and Practice specifically to focus on creative process and AI as part of how animators learn to make work. The thesis at the start was that AI was going to reshape animation as a medium, and the question was not whether to teach it but how to embed it in the curriculum so students learn it as part of their creative process rather than as a separate technical specialty.
USC's School of Cinematic Arts already had decades of cinematic storytelling tradition. What we did with XA was put AI inside that tradition. The conceptual thinking, the storytelling, the cinematic history come first. AI is one of the many tools available to them, sitting alongside hand-drawing, paint, 3D, and live-action footage. Students do not learn AI in one course and animation in another. They learn both side by side.
The students who arrive at the program are usually self-selected for it. They show up technically fluent, with their own GPU-equipped laptops. What we offer them is the storytelling, the cinematic history, and the conceptual frame. They bring the technical nimbleness.
<Quote>They are way ahead of the curve. They are ahead of the faculty in the way they work, technically, but not so much artistically. That is what we are there to deliver.</Quote>
<Figure src="https://media.comfy.org/website/customers/kathy-smith/usc-campus.png" alt="USC School of Cinematic Arts" caption="USC School of Cinematic Arts. Source: USC Today" />
</Section>
<Section id="topic-2">
### How do you actually structure an AI assignment? Walk us through one.
In my Animation, Dreams, and Consciousness class, I have the students document their dreams and then use the dream as the source. Some of them draw, some of them write. The dream becomes the prompt, and they generate the image and emotion of the dream. I love when you get six fingers and weird stuff happening in the algorithms. Our human perception in dreams is often doing the same thing. Therefore, AI is evolving and dreaming with us.
That structure is deliberate. The students are not asking the model to produce work for them. They are using it as a layer of their process, alongside hand-drawing and painting and 3D rendering and live-action footage. The work that comes out is theirs because the creative decisions are theirs. The tool just gives them new ways to reach what they were trying to make.
There is a fear factor around AI, and I understand it. There has been a lot of scraping of artists' work, and that conversation is real and is going to take time to resolve. But I have been working with AI conceptually since 1998, and the way I describe the data sets to my students is that they are a repository of all of our creation. It is like the collective unconscious of the human mind. Artists have always drawn from everything around them.
<Quote>What really matters is what the artist does with it, *intentionality*.</Quote>
</Section>
<Section id="topic-3">
### Why does ComfyUI specifically fit the way your students work?
It is the node-based system. Those who have done Houdini feel very at home in Comfy. You can work with the prompts, but it is very visual. That is what they are used to. They are not asking a black box for an output. They are building a workflow.
And it stays in its lane. The students are not using Comfy to make AI art. They are using Comfy as one node graph alongside Blender, hand-drawn frames, paint, and live-action footage. The reason it fits is that it does not try to be the whole pipeline. It is one stage of a creative practice that still has cinema at its core.
What also matters is that Comfy is open and inspectable. The students can see what the model is doing at each step, fork a workflow, swap a sampler, drop in a custom node, and share what they built with the next cohort. That is closer to how an animation studio tradition has always behaved, with techniques passed along and improved rather than hidden behind a paywall.
They also work across whatever hardware they have: Comfy Cloud at home and when they are mobile, the portable version on their personal laptops, and the research computer in my office for the high-end runs. Animation students do not sit in one cubicle for a thesis project. They work everywhere.
</Section>
<Section id="topic-4">
### Tell us about the work coming out of the program.
The pattern shows up across the cohort: the AI is in service of the cinematic story, not in place of it. Three students walked us through how Comfy actually sits inside their pipelines.
#### Sijia Zheng — Ori & Kiddo
<Figure src="https://media.comfy.org/website/customers/kathy-smith/ori-kiddo.png" alt="Sijia Zheng, Ori & Kiddo" />
**What Comfy enabled:** an oil-paint, brush-stroke dream look that "other AI tools cannot possibly make," held consistent across shots with IP-Adapter style transfer and a custom LoRA.
*Ori & Kiddo* follows two ghosts who, after the universe dies, search for old human memories, rediscover love, and reverse the universe back into being. Most of the film is hand-drawn 2D. Comfy enters in the dream sequences, where the ghost Kiddo dreams of past lives and the look had to be unlike anything else in the film. Sijia drew stylized reference images first, then used them as the style reference over video clips through an IP-Adapter workflow to produce long, oil-painted, brush-stroke sequences. The same control shows up in shots where Sijia appears on screen: real footage, masked in Comfy to change the haircut and swap the background. For a look that has to stay locked, Sijia trains a LoRA and runs it through Comfy.
Sijia found Comfy in early 2025 while hunting for a style-transfer tool that Midjourney and DALL-E could not deliver, testing it on a stylized animated-film-look conversion.
<Quote>It totally broke my mind. Most of the time, I think I'll just stand on other people's shoulders. The workflows are already pretty amazing, and I'll base on the workflows and add something that I want.</Quote>
Since *Ori & Kiddo*, Sijia has taken the same Comfy-anchored workflow into professional commercial video work, on deadlines as tight as four days.
#### Ion Yunyang Li — L1LY
<Figure src="https://media.comfy.org/website/customers/kathy-smith/l1ly.gif" alt="Ion Yunyang Li, L1LY" />
**What Comfy enabled:** a repeatable multi-step pipeline that drops the filmmaker into a photorealistic world, because "a sequence of a prompt is not the only thing you need."
Ion taught himself ComfyUI in early 2025, from tutorials in the generative-AI community, and built his most distinctive Comfy work in a body-and-environment project: start from 3D-model stills, convert them to a pencil-sketch style so the model would not over-study the original 3D aesthetic, generate photorealistic frames from the sketches, build character T-poses, composite himself into the scene, and animate the stills with a video model.
<Quote>A sequence of a prompt is not the only thing you need. You need many different settings, and it is very hard to redo those settings every time.</Quote>
What he values as much as the pipeline is where it can run: the same Comfy setup moves across a workstation in his school cubicle, a remote session from his apartment laptop, and fully cloud-based instances, depending on where he is.
#### Sihan Wu — Scary Coaster
<Figure src="https://media.comfy.org/website/customers/kathy-smith/scary-coaster.gif" alt="Sihan Wu, Scary Coaster" />
**What Comfy enabled:** roughly 100 hand-drawn keyframes carried through a single workflow so a two-to-three-minute film stays visually consistent, on his first-ever AI project.
*Scary Coaster* (December 2024) was Sihan's first project ever made with AI. Coming from a digital-media and game-development undergrad, Sihan joined Professor Smith's Expanded Animation class and wanted something more controllable than the prompt-only tools on offer. The workflow he built: draw roughly 100 rough keyframes by hand, run them through Comfy to find a stylized Chinese-horror look, pick the favorite, then generate the in-betweens to produce the full sequence.
<Quote>I want to have a more controllable flow. I don't want to just use prompts and generate random images. I just use one workflow to create the whole two or three minutes, and I can make everything look very consistent.</Quote>
Sihan is honest that the on-ramp was steep: learning from the official ComfyUI GitHub workflows, combining them, and debugging Python environments along the way. His ask was specific: an official, beginner-to-advanced tutorial series. And his view on where AI should head next was equally specific: aim it at "the very time-consuming but not that creative process, like creating in-betweens," and leave the creative decisions to the artist.
</Section>
<Section id="topic-5" title="At a glance">
<AtAGlance rows={[
{ label: "Program", value: "Expanded Animation: Research + Practice (XA), USC School of Cinematic Arts" },
{ label: "Founded", value: "2022, AI embedded in the MFA curriculum from day one" },
{ label: "Setup", value: "Students' own GPU laptops + Comfy Cloud + lab research machine" },
{ label: "Core techniques", value: "IP-Adapter style transfer, custom LoRAs, masked compositing, keyframe-to-in-between pipelines" },
{ label: "Outcomes", value: "Amazing student works from Sihan, and Ion, Sijia" }
]} />
</Section>
<Section id="topic-6">
### What excites you about where this is going?
I have a philosophy that everyone is an artist. They just forget that they are an artist. Creativity drives everything, and the tools we are getting now make it possible for more people to find that capacity in themselves. ComfyUI, because it is node-based and visual and open, gives non-programmers a way forward that is honest about how the model works. It does not pretend the AI is doing something magical. It shows the artist what is happening at each step.
The two basic rights of human life are health and education. The work Comfy is doing on the education side is touching something integral. The students who came through XA are already extending the work in directions the program did not anticipate, and the next generation of educators and students will keep doing the same.
<Quote>Everyone is an artist. They just forget that they are an artist.</Quote>
</Section>
<AuthorBio people={[{ name: "Kathy Smith", photo: "https://media.comfy.org/website/customers/kathy-smith/kathy-smith.jpg", bio: `Kathy Smith is Professor of Cinematic Arts at USC's School of Cinematic Arts and inaugural director (2022-2023) of Expanded Animation: Research + Practice (XA), the experimental MFA program she helped found in 2022 to integrate AI into animation pedagogy from the first day of the degree. To date she is the longest-serving chair of combined USC animation programs and has been exploring concepts of AI in her creative practice since 1998.` }]} />
<EducationCta />

View File

@@ -0,0 +1,56 @@
---
title: "Comfy and UAL's Creative Computing Institute Announce Creative Campus Partnership"
category: "CREATIVE CAMPUS PARTNERSHIP"
description: "Comfy announces Creative Campus Partnership to support teaching and research across UAL CCI's masters, PhD, and industry programmes"
cover: "https://media.comfy.org/website/customers/ual-cci/cover.png"
order: 9
sections:
- id: topic-1
label: "INTRO"
- id: topic-2
label: "WHAT CCI DOES"
- id: topic-3
label: "THE PARTNERSHIP"
- id: topic-4
label: "ABOUT CCI"
---
<Section id="topic-1">
Comfy Org, the team behind ComfyUI, the open-source node-based interface for generative AI, and the Creative Computing Institute (CCI) at University of the Arts London today announced a Creative Campus partnership, making CCI a founding partner of the [Comfy Education Initiative](https://comfy.org/education).
</Section>
<Section id="topic-2">
CCI already runs ComfyUI at every level of the institute. On the Applied Machine Learning for Creatives masters course, students build image, video, audio, and text workflows, train their own models, and construct interactive pipelines. PhD researchers use Comfy for fine-tuning, custom datasets, and custom node development. The institute also uses ComfyUI in industry training, where its node-based interface gives non-technical collaborators a way into generative AI that code alone does not.
<Quote name="Prof Mick Grierson, Research Leader, UAL Creative Computing Institute">ComfyUI has become part of how we teach, research, and work with industry. It is one of the few generative AI environments where the workflows our students build are portable, inspectable, and forkable, and that open-source foundation is exactly what a university should be teaching on.</Quote>
</Section>
<Section id="topic-3">
Through the partnership, CCI educators and students gain access to classroom licenses with central billing & administration, educational discounts, early access to upcoming team features, a dedicated educator community with direct support from the Comfy team, and a voice in shaping the future of the education program.
Creative Campus partnerships are the deepest tier of the program: a direct, ongoing collaboration in which an institution works hand in hand with the Comfy team to roll out ComfyUI across teaching, research, and industry training.
<Quote name="The Comfy Team">CCI is the model we hope every creative campus follows: ComfyUI in the masters classroom, in PhD research, and in industry collaboration, all at once. As our first Creative Campus Partner, they are helping us design an education program that works the way universities actually work.</Quote>
<Figure src="https://media.comfy.org/website/customers/ual-cci/cci-camberwell.jpg" alt="Creative Computing Institute campus at UAL" caption="Creative Computing Institute Campus. Photo: Ana Escobar, courtesy UAL." />
The institute is leading a major £1.5 million publicly funded research programme developing copyright-compliant audiovisual foundation models for the UK's creative industries. Bringing together expertise in sound, image, and artificial intelligence, the project is building open tools and responsible AI national infrastructure designed to support UK creative production, research, and experimentation across the sector.
The outputs of the research will explore wider dissemination and adoption through open, node-based tools such as ComfyUI to support experimentation, workflows, and collaboration around emerging multimodal AI systems.
UAL CCI joins a founding cohort of educators and institutions featured at the launch of the Comfy Education Initiative, alongside researchers such as CCI co-founder Dr. Phoenix Perry, whose Antigravity Machine project Comfy supports as an industry partner.
</Section>
<Section id="topic-4" title="About the Creative Computing Institute at UAL">
The Creative Computing Institute at University of the Arts London applies computing to creativity and social impact, operating at the intersection of computational technologies and creative practice, teaching undergraduate, postgraduate, and PhD students alongside research and industry collaboration.
</Section>
<EducationCta />

View File

@@ -0,0 +1,103 @@
---
title: "The tool that expands my art: Xindi Zhang's Oscar-shortlisted thesis, built in ComfyUI"
category: "CREATIVE CAMPUS SHOWCASE"
description: "How a USC Expanded Animation thesis became a Student Academy Award winner, an Oscar shortlist entry, and helped land a job at Amazon — with the artist's own illustrations as the style guide."
cover: "https://media.comfy.org/website/customers/xindi-zhang/cover.webp"
order: 5
sections:
- id: topic-1
label: "INTRO"
- id: topic-2
label: "WHY COMFYUI"
- id: topic-3
label: "THE PIPELINE"
- id: topic-4
label: "AT A GLANCE"
- id: topic-5
label: "WHAT'S NEXT"
---
<Section id="topic-1">
<Embed src="https://player.vimeo.com/video/1131160045" title="The Song of Drifters by Xindi Zhang" />
*From The Song of Drifters. Film images: Xindi Zhang.*
### Tell us about The Song of Drifters. What is it about, and where did it start?
The Song of Drifters is a documentary animation about people caught between leaving and returning, wanderers who drift through unfamiliar cities, holding onto memories of a homeland out of reach and searching for a sense of belonging. The title is a direct translation from an ancient Chinese poem about a mother's love for a child who leaves her hometown. My version takes the opposite point of view, from the child's perspective.
I built the film in ComfyUI. When I started, I was not trying to show what AI could do. I was trying to prove something almost opposite.
<Quote>It started as a challenge to the stereotype that AI-generated work is generic and cheap. I wanted to prove that AI could be an amplifier for personal vision, not a replacement for it.</Quote>
</Section>
<Section id="topic-2">
### You came to this from illustration, not engineering. How did you end up in ComfyUI?
I started as an illustrator. I earned my BFA in illustration at the Rhode Island School of Design, then worked as a game concept artist, where I picked up shaders, Unity, and Unreal. That technical side made me a fast learner with new tools. Later I went to USC's School of Cinematic Arts for an MFA in Expanded Animation, where I studied with Professor Kathy Smith.
By my thesis year I had moved from Stable Diffusion's standard interfaces to ComfyUI, because I think in node-based structures and I wanted to control every step. Most AI tools are one click: you prompt, you click, you get a result. That is not what I wanted.
<Quote>I want to control the process, and the process is even more important than the result itself. For artists like me, I don't want to automate anything. I want to participate in every single stage of designing the workflow. That's the fun part of it.</Quote>
</Section>
<Section id="topic-3">
### Walk us through the pipeline. What were you actually feeding the model?
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/balloon-workflow.png" alt="Xindi's ComfyUI workflow for the balloon sequence">Xindi's ComfyUI workflow for the balloon sequence. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
My core technique was style transfer in Stable Diffusion 1.5, driven by IP-Adapter and ControlNet. What mattered most was what I fed it: my own work. The base materials were live-action footage I shot on an iPhone 15 Pro and 3D animation I built in Blender. The AI restyled imagery I had already made. It did not invent it.
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/film-still.jpg" alt="Style-guide still from The Song of Drifters">Style-guide still from The Song of Drifters. Source: [xindizhangart.com](https://xindizhangart.com).</Figure>
<Quote>Unlike most AI-generated videos, which use other artists' works from the model, I use my own illustrations as the style guide.</Quote>
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/style-transfer-workflow.json" label="Download Xindi's style transfer workflow (json) on ComfyUI" />
I also trained custom LoRAs on my own video, footage of the cities I had lived in. Capturing that footage became a vital part of the documentary process. Wandering through the streets where I once lived let me reconnect with those cities. Most of it never appears in the final cut, but it lives in the visuals as training data. The hybrid pipeline made rendering the final look more efficient and saved more time for ideation.
For the dream sequences I combined animated 3D with AI morphing, moving from abstract to concrete to mimic the feeling of being half awake.
<Video src="https://media.comfy.org/website/customers/xindi-zhang/bts-clip.mp4" poster="https://media.comfy.org/website/customers/xindi-zhang/bts-poster.jpg" caption="BTS clip, AI morphing. Source: Xindi Zhang." />
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/morphing-workflow.json" label="Download Xindi's AI morphing workflow (json) on ComfyUI" />
</Section>
<Section id="topic-4" title="At a glance">
<AtAGlance rows={[
{ label: "Program", value: "USC School of Cinematic Arts — MFA Expanded Animation (thesis)" },
{ label: "Base materials", value: "iPhone 15 Pro live-action; her own Blender 3D animation" },
{ label: "Core technique", value: "Style transfer in SD 1.5 via IP-Adapter + ControlNet, in ComfyUI" },
{ label: "Style source", value: "Her own illustrations + custom LoRAs trained on her own city footage" },
{ label: "Finishing", value: "Depth, mask, and fade passes in After Effects; heavy compositing" },
{ label: "Outcome", value: "Student Academy Awards Golden Award (2025); 98th Academy Awards shortlist; AI Creative role at Amazon AI Studio" }
]} />
</Section>
<Section id="topic-5">
### The film won gold at the Student Academy Awards and was shortlisted for the Oscars. What's next?
I made the film for creative reasons, not career ones. I honestly did not expect it to connect to a job at all. Then it won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the Oscars, and the calls started.
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/awards.png" alt="Xindi Zhang at the 2025 Student Academy Awards" caption="Xindi Zhang at the 2025 Student Academy Awards. Source: Oscars Press Office." />
What people wanted was the combination: someone who understands both traditional craft and AI tools. I now work as an AI Creative at Amazon AI Studio building custom production pipelines. I see that same demand across the industry, with ComfyUI experience starting to show up as a requirement in job postings at major studios and design agencies.
<Quote>It's not the tool that steals my art. It's the tool that expands my art.</Quote>
My advice to other students is not really about software. AI is just another tool to convey ideas, but nothing is more important than the story itself. If you use AI, use it on purpose. The more you understand it, the more freedom you have to make work that is genuinely yours.
</Section>
<AuthorBio people={[{ name: "Xindi Zhang", photo: "https://media.comfy.org/website/customers/xindi-zhang/profile.jpg", bio: `Xindi Zhang is a Chinese animation director and visual artist (RISD BFA in illustration, 2020; USC MFA in Expanded Animation, 2025). The Song of Drifters won the Golden Award at the 2025 Student Academy Awards and was shortlisted for the 98th Academy Awards. She works as an AI Creative at Amazon AI Studio, has collaborated with Sony Music's immersive studio, and is now on the faculty at the University of South Florida.` }]} />
<EducationCta />

View File

@@ -0,0 +1,148 @@
---
title: "以全新方式观察世界Golan Levin 教授如何在卡内基梅隆大学用 ComfyUI 教学"
category: "创意校园展示"
description: "\"对我而言ComfyUI 不只是生成式 AI。它是一台面向全新创作方式的图像处理工作站。\""
cover: "https://media.comfy.org/website/customers/golan-levin/cover.png"
order: 7
sections:
- id: topic-1
label: "简介"
- id: topic-2
label: "ComfyUI 的定位"
- id: topic-3
label: "图像合成"
- id: topic-4
label: "图像分析"
- id: topic-5
label: "计算机视觉实验室"
- id: topic-6
label: "概览"
- id: topic-7
label: "学生作品"
---
<Section id="topic-1">
<Figure src="https://media.comfy.org/website/customers/golan-levin/augmented-hand.jpg" alt="Golan Levin, Augmented Hand Series" caption="Golan LevinAugmented Hand Series2014与 Chris Sugrue 和 Kyle McDonald 合作。摄影Gerlinde de Geus由 Cinekid 提供。" />
对许多人来说,艺术中的 AI 意味着图像生成。但在过去二十年的大部分时间里Levin 一直在教艺术家如何让计算机去解读、分析和度量视觉世界。他自己的艺术作品长期通过实时计算机视觉系统探索机器感知,自 2024 年起,他越来越多地使用 ComfyUI 来讲授这些原理。
对 Levin 而言ComfyUI 与其说是图像生成器,不如说是一台图像处理工作台。学生用它来组装用于分割、追踪、深度估计等各类计算感知的自定义工作流。由此形成了一个环境:艺术家可以直接用研究级的机器学习工具做实验,并把它们组合成自己设计的系统。
</Section>
<Section id="topic-2">
### ComfyUI 在你想做的事情中处于什么位置?
我培养的是创意技术专家和具备技术素养的艺术家。我这门创意编程课上典型的学生是真正的复合型人才:一位同时在学习计算机科学、人机交互或信息科学的艺术或设计本科生。他们有很强的视觉能力、深厚的文化素养和出色的算法思维能力,但我的课程可能是他们第一次有机会把这些能力融会贯通。
对我来说,这意味着给学生他们能够理解、修改和重组的工具,让他们构建自己设计的系统,而不是把创意软件当作一成不变的既定条件。这正是我如此推崇面向艺术的社区驱动、开源软件开发工具包的原因。
<Quote>ComfyUI 是我找到的第一个既有"低门槛"又有"高上限"的 AI 工具。它极其强大而灵活,让艺术家能够用最前沿的算法设计自己的 AI 工作流。同时它又跳过了应付古怪的 GitHub 仓库和过时的 Colab notebook 带来的种种烦恼。</Quote>
### 学生们此前卡在哪里?
学生常常发现自己被困在两个世界之间。一边是商业 AI 工具,效果惊艳,但可定制的空间有限。另一边是大学和实验室发布的研究项目,其软件往往难以安装、文档不全,或者早已过时。
ComfyUI 弥合了这一鸿沟。它让学生通过一个自己能够理解、修改和扩展的环境去接触最先进的算法。他们不必削足适履地把自己的想法套进工具内置的工作流,而是可以构建能反映自身兴趣和问题的工作流。
<Quote>我的学生是探索者。他们是会写代码的艺术家,想要构建前所未有的系统。</Quote>
</Section>
<Section id="topic-3">
### 第一个练习:在 ComfyUI 内用 p5.js 草图驱动图像合成
在 Levin 的一个入门练习中——这是学生第一次接触 ComfyUI 环境——他们直接在 ComfyUI 里写一个简单的 p5.js 草图,然后用他们画出的形状加上一段文字提示词,去引导一次 Stable Diffusion 图像合成。他们把生成的成对图像记录下来:左边是他们的 JavaScript 画布绘图,右边是 AI 合成结果。此前他们已经花了几周时间费力地想从 p5.js 中榨出一点细腻表现,因此看到简单形状竟能得到这样的结果,他们乐在其中,也由此对 Stable Diffusion 的工作原理有了很多领悟。
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-landscape.png" alt="p5.js 椭圆引导 Stable Diffusion 合成" caption={`在 p5.js 中绘制的一些宽椭圆(左)引导 Stable Diffusion 合成,提示词为 "rolling hills, foggy day"(右)。`} />
它运行在一个节点式画布上,艺术专业的学生上手很快,因为它的工作方式与他们已经熟悉的工具相似。
<Figure src="https://media.comfy.org/website/customers/golan-levin/p5-workflow.png" alt="使用 ComfyUI-p5js-node 的模板 ComfyUI 工作流" caption="学生拿到的模板 ComfyUI 工作流。它使用了 Ben Fox 制作的自定义 ComfyUI-p5js-node。取自 Levin 的 60-212 课程仓库。" />
*自己动手试试:[json 文件](https://media.comfy.org/website/customers/golan-levin/p5-in-comfy.json)(仅限 Comfy Local*
</Section>
<Section id="topic-4">
### 许多艺术家一开始把 ComfyUI 用于生成式 AI。你的用法却不同。
也许是吧。我感兴趣的是把 AI 作为一种拓展感知的框架,所以在过去 25 年里,我使用机器学习和计算机视觉的很多方式都是为了图像分析,而非图像合成。本质上,我用计算机视觉去理解视频和图像,再用提取出的信息创造出新类型的交互体验。在课堂上,我用 ComfyUI 帮助学生学会"像机器一样观察"。所以我让学生把 ComfyUI 当作分析图像的框架,而不只是生成图像。例如,我让他们取一张输入图像,然后用 AI 从中计算出新的图像,比如语义分割("哪些像素属于这头大象?")和单目深度估计("每个像素有多远?")。接着学生构建一个交互作品,去解读原始图像,但使用的是五个信息通道而非三个:常规的红、绿、蓝,加上深度,再加上分割。在我的演示项目里,分割把大象染成粉色,背景像素则根据 AI 判断的远近改变大小。
<Figure src="https://media.comfy.org/website/customers/golan-levin/depth-segmentation.png" alt="ComfyUI 中的语义分割与单目深度分析" caption={`一张输入图像在 ComfyUI 内被分析:语义分割和单目深度,供给一个五通道的 "Custom Pixel" 练习。取自 Levin 的 60-212 课程仓库。`} />
*自己动手试试:[演示项目](https://editor.p5js.org/golan/sketches/-_cFmLtoP) · [教案与工作流](https://github.com/golanlevin/60-212/tree/main/lectures/comfy/image_analysis#3-segment-the-image-with-ai)*
*工作流文件:下载 [.json](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.json),或[将工作流嵌入元数据的 .png](https://media.comfy.org/website/customers/golan-levin/image-analysis-workflow.png)(把它拖入 ComfyUI 即可加载图。)*
<Quote>我希望学生明白AI 不只是生成图像的工具。它也是一种用于感知、度量和分析的工具。</Quote>
为此构建的计算机视觉工具通常面向开发者和企业,预设了一套工程化的工作流。我希望我的艺术专业学生能在一个他们已经习惯思考的环境里接触到分割、深度和追踪,而不必先搭建一条生产管线。
### 当 ComfyUI 进入工作流后,发生了什么变化?
有两点。第一,它运行在一个节点式画布上,许多艺术专业学生已经从 TouchDesigner、Max/MSP 和 Grasshopper 这类环境中熟悉了这种方式——只不过它在浏览器里运行,而且是为 AI 服务的。因此,学生可以专注于机器学习工作流背后的思想,而不必先去学习一整套全新的交互范式。第二,它拉近了研究实验室与课堂之间的距离。
<Quote>从实验室到你的课堂有一条快速通道。爱好者们把 AI 研究代码转化为 Comfy 节点,往往在其发布几天之内就完成,这已成为常态。</Quote>
ComfyUI 生态最了不起的一点,是新研究成果变得可用的速度。一篇计算机视觉论文可能在 CVPR 或 ICCV 上发表,几天之内社区里就有人把它封装成一个可复用的 ComfyUI 节点。对教育者而言,这极大地缩短了研究实验室与课堂之间的距离。学生不必花上数周去重建一套实验性的软件环境,几乎可以立即开始探索其中的核心思想。
云端对可及性和公平也很重要。我的大多数学生没有强大的 GPU 工作站,我也不希望他们能否使用先进工具取决于个人硬件的档次。云平台让一个班的每个人都能在同一个环境里、用同样的模型工作,无论他们手头恰好是哪台笔记本电脑。
</Section>
<Section id="topic-5">
### 在你的高阶实验性捕捉工作室里,你把 ComfyUI 变成了一个计算机视觉实验室。
这门课的目标是借助各种技术,帮助我们以全新的方式观察世界:极快的、极慢的、极小的、极大的,以及超出人类感知的光谱,比如红外和紫外。它关乎培养学生的好奇心。但这个工作室的限制在于硬件。我们有一台能以每秒 10 万帧拍摄的相机、一台高分辨率热成像相机,还能使用一台电子显微镜——但我们有 20 个学生。我们没法总是让所有人排队去用某台稀有的相机,那会成为瓶颈。
<Quote>我得给他们一些工具,让他们都能在自己的硬件上运行,用来以全新的方式观察世界。</Quote>
ComfyUI 让学生能用自己的手机去提出以前提不出的问题。于是他们用胶带把手机相机粘在窗户上,录下窗外流逝的世界,然后用 LocateAnything 和 SAM3 这些 ComfyUI 节点来追踪事物生成一份份提炼相机所见的数据文件。ComfyUI 由此成为一个用于计算观察的实验室,让学生能够对图像和视频提出那些原本很难表述的问题。
### 你也会亲自把小众的研究库封装成 ComfyUI 节点。
ComfyUI 生态最了不起的一点是围绕它形成的社区。GitHub 上有一位我很敬佩的人Kijai他不断把计算机视觉实验室的各种库变成 ComfyUI 节点。他已经做了数百个,在把实验室级的模型变成人人可用的工具这件事上,他做得比任何人都多。我和我的学生也开始这么做。"小众"这个词用得很贴切。眼下我盯上了一个动物学实验室,他们发布了一个很好的、用于追踪昆虫腿部的库。做这个库的人大概根本不知道 ComfyUI 是什么。但我想把那个算法用到我的学生身上,而且外面一定还有别人会爱上它。
### 你在学生身上看到的更大的规律是什么?
我的学生是探索者。他们一看到新工具,立刻就会开始琢磨它还能连接到什么别的东西。他们不断探索:我应该能把这个东西和那个东西组合起来。这正是要给他们一个可以在上面搭建的系统、而不是一个告诉他们只能做什么的工具的全部理由。
<Quote>我们培养的是想要发明新形式、新体验的学生,而不只是复制已有事物的学生。</Quote>
</Section>
<Section id="topic-6" title="概览">
<AtAGlance rows={[
{ label: "课程", value: "中阶工作室创意编程60-212实验性捕捉与 Nica Ross 合授)" },
{ label: "层次", value: "本科(二年级工作室 + 高阶工作室,约 20 名学生)" },
{ label: "配置", value: "云端托管的 ComfyUI在学生自己的笔记本电脑上运行" },
{ label: "核心技术", value: "p5.js 驱动的合成语义分割单目深度LocateAnything + SAM3 追踪" },
{ label: "独特视角", value: "把 ComfyUI 当作计算机视觉实验室,而不只是生成器" }
]} />
</Section>
<Section id="topic-7" title="学生作品">
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-tippi.png" alt="Tippi Li 的学生作品" caption={`"nuclear explosion",作者 Tippi Li`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-xiao.png" alt="Xiao Yuan 的学生作品" caption={`"Chinese painting, plants, ink, transparent",作者 Xiao Yuan`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-aarnav.png" alt="Aarnav Patel 的学生作品" caption={`"NASA space image of a new cosmos detected",作者 Aarnav Patel`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-jeffrey.png" alt="Jeffrey Wang 的学生作品" caption={`"Dream Scene Painting",作者 Jeffrey Wang`} />
<Figure src="https://media.comfy.org/website/customers/golan-levin/student-kai.gif" alt="Kai Okorodudu 的学生作品" caption={`"Electric hand",作者 Kai Okorodudu`} />
</Section>
<AuthorBio people={[{ name: "Golan Levin", photo: "https://media.comfy.org/website/customers/golan-levin/author-golan.png" }]}>Golan Levin 是卡内基梅隆大学计算艺术教授,与 Tega Brain 合著有《Code as Creative Medium》。今年秋季他在卡内基梅隆大学开设两门用 ComfyUI 授课的课程:"中阶工作室:创意编程"60-212围绕 p5.js 展开;以及"实验性捕捉",一门关于计算摄影与拓展摄影的工作室课程,与 Nica Ross 合授。Levin 还以由实时机器视觉驱动的交互艺术装置广为人知,例如他与 Kyle McDonald 和 Christine Sugrue 共同创作的 [Augmented Hand Series](https://flong.com/archive/projects/augmented-hand-series/index.html)2014。</AuthorBio>
<EducationCta />

View File

@@ -0,0 +1,149 @@
---
title: "从节点图到建筑立面Ina Conradi 的 NTU 学生如何用 ComfyUI 创作建筑尺度的公共艺术"
category: "创意校园展示"
description: "在新加坡的 NTUIna Conradi 的学生为楼宇大小的 LED 墙创作 90 秒影片——这是提示词框无法渲染、而 ComfyUI 可以胜任的作品,它们从校园走向杭州西湖媒体立面,每天面对上百万观众。"
cover: "https://media.comfy.org/website/customers/ina-conradi/cover.png"
order: 6
sections:
- id: topic-1
label: "简介"
- id: topic-2
label: "画布"
- id: topic-3
label: "为何选择 ComfyUI"
- id: topic-4
label: "2026 年课题"
- id: topic-5
label: "学生作品"
- id: topic-6
label: "公共屏幕"
- id: topic-7
label: "概览"
---
<Section id="topic-1">
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig1-quantum-logos.jpg" alt="Media Art Nexus LED 屏幕上的 Quantum Logos (Vision Serpent)" caption="Quantum Logos (Vision Serpent)Mark Chavez 与 Ina Conradi。实验动画Media Art Nexus LED 屏幕15 米 × 2 米新加坡。摄影Quek Jia Liang。" />
### 构建一条从工作室到屏幕的 AI 艺术管线
自 2022 年以来Ina Conradi 撰写并讲授 NTU 的两门 AI 课程DM2012《AI 生成艺术探索》(本科)和 AP7055《创意机器时代的艺术》研究生。每门课每学期约 30 名学生。与她并肩打磨这条制作管线的是 Mark Chavez——一位动画老兵DreamWorks、Rhythm & Hues和 ComfyUI 的早期采用者。两人共同策展这些课程所服务的平台:一面 15 米 × 2 米的 LED 墙2016 年作为 Media Art Nexus 安装于 NTU 的 North Spine如今由 NTU 博物馆以 NTU Index 之名运营,每学期仍在接收新作品。
来自这面墙的作品已走向新加坡Ten Square、杭州和重庆的巨型公共屏幕并与包豪斯大学、柏林艺术大学以及汉堡易北爱乐音乐厅展开合作。
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig2-nature-sanctuary.jpg" alt="西湖媒体立面上的 Nature Sanctuary 3000" caption="Nature Sanctuary 3000Sowmya Sreeshna。实验动画西湖媒体立面170 米 × 18 米中国杭州。摄影Limpid Art。" />
</Section>
<Section id="topic-2">
### Ina你的学生并不为笔记本电脑制作影片。为什么要用楼宇大小的屏幕
因为这种格式本身就是教学。一部 90 秒、横向 6K、8:1 全景的影片,不可能靠一次侥幸的提示词得来。它必须被"构图"。而这些屏幕是真实的:最出色的学生作品会在 NTU Index——我们校园里那面 15 米 × 2 米的墙——上播放并通过城市数字皮肤艺术节CDSA走向中国和欧洲的城市立面。当一名学生知道每天可能有上百万人在杭州从他的影片前走过关于"手艺"的讨论就变得不一样了。
### Mark请描述一下这块画布。
基本上,我们为新加坡和中国的超大型媒体 LED 屏幕做构图。我们在新加坡有一块八比一的屏幕,是 5,888 × 768 像素。学生在课上创作图像,通常横向约 6K 分辨率,一幅长长的横向全景。成果是 90 秒的短片。两分钟、90 秒,我不会改。我喜欢这种格式,因为它在课程范围内是可管理的。
</Section>
<Section id="topic-3">
### 这种格式会让大多数 AI 工具崩溃。发生了什么?
Runway 是我们使用的工具之一用的是对学校很合适的教育版方案。我们撞上的限制是格式Runway 以 16:9 工作,而我们的 6K 全景落在它的范围之外。上学期 Midjourney 在我们的分辨率下给我们带来了麻烦,放大也很困难。所以我们在扩展工具组合,把 ComfyUI 引入到已有的流程之中。
<Quote>ComfyUI 给出了最干净的结果。构图之后要把一幅 1:8 的全景放大到 8K这确实很难而 ComfyUI 是唯一让学生能够把图像、运动和放大模型组合在一起的管线。</Quote>
### 预算方面呢?
预算会一直是个问题。学校对我们支持得很好,但每学期都有新工具出现,学生想去尝试并搭建自己的管线。按月计的单座席许可并不契合一个学期的运作方式。让学生在本地运行 ComfyUI 很难:大多数笔记本电脑没有足够 VRAM 的 GPU把它跑起来需要真正的反复试错。许多人更愿意在家里工作但硬件挡住了他们于是他们只能来实验室。另一些人用了 Comfy Cloud。它按订阅收费但仍比预付费工具便宜得多而且结果更好。无论哪种方式他们追求的都是同一样东西一条无论身在何处都能持续打磨的管线。
### Ina你坚持说这些课程与工具无关。它们到底关乎什么
我的课不是要教某一个工具。它是学生跨平台与之互动的一套有回应的系统——通过持续的对话去指导、批评并塑造输出。ComfyUI 契合这一点:一张节点图是一个你可以阅读、质疑并重建的论证。提示词框则不是。新加坡的学生在技术上很快就变得娴熟。他们从艺术教育中需要的,是质疑自己所创作之物的语言,而不仅仅是创作它的技能。
</Section>
<Section id="topic-4">
### Ina2026 年的课题把学生带向海洋。这个作业是什么?
这个项目是 The Liquid Commons: Bringing Ocean Science into Global Media Architecture流动的公域把海洋科学带入全球媒体建筑与 OceanXOceanXplorer 科考船背后的机构)以及 CDSA 2026 的节庆主题对话开发而成。课题很严格不要图解科学而要转译科学。2026 届是第一届用 ComfyUI 配合 Topaz 放大来制作这些影片的学生,他们同时朝着两个真实的截止期努力。他们的作品正在争取入选今年十月在新加坡举办的 OceanX 峰会经评审团遴选的作品将在城市数字皮肤艺术节期间于杭州西湖媒体立面上放映170 米 × 18 米,每天约上百万观众。
<Quote>交付规格本身就告诉你工具为何重要:最终导出为 5,888 × 768 像素,必要处达 8K。这是任何提示词框都无法完成的课题。</Quote>
</Section>
<Section id="topic-5">
### Mark学生的作品是什么样子的
到目前为止,大约有八名学生用 Comfy 完成了他们的影片,而且都相当酷。它们出人意料又富有洞见,因为它们不受游戏引擎图形的限制。有一名学生尤为突出:他把 Comfy 里的每个模型都试了个遍,走得也最远。
2026 届的三个项目展现了作品的广度。
**The Tao of Water**Wang ZilinAP7055借《道德经》来解读海洋是一条从水到海洋植物、再到虚空并回归本源的三段式弧线。管线从 Pinterest 研究面板出发,经 Midjourney 进入 ComfyUI在那里 Nano Banana 把单帧扩展成无缝全景Kling 3.0 以完整的 5,888 像素宽度实现首帧到末帧的运动动画,之后再用 Premiere 剪辑。
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig3-tao-of-water.jpg" alt="NTU Index 屏幕上的 The Tao of Water" caption="The Tao of WaterWang Zilin。实验动画NTU Index 屏幕15 米 × 2 米新加坡。摄影Quek Jia Liang。" />
**microscophony**Jiin KoAP7055融合了 *microscopic*(微观)与 *micropolyphony*——Ligeti 用来指那种密集交织、模糊成一整片声云的声部之网。素材源自 OceanX 拍摄的深海微生物显微镜影像被转译为图形记谱法Ligeti、Xenakis、Cardew的视觉逻辑使这幅全景成为一份可供聆听的乐谱。图像经由 Midjourney 和 Nano Banana视频经由 ComfyUI 配合 Vidu Q2声音设计在 Ableton Live 中完成,不同的声音质地对应不同的视觉形态。
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig4-microscophony.jpg" alt="NTU Index 屏幕上的 microscophony" caption="microscophonyJiin Ko。实验动画NTU Index 屏幕15 米 × 2 米新加坡。摄影Quek Jia Liang。" />
**GO! PLASTIC**Jianwei HoeDM2012是一部关于海洋塑料的作品它的制作日志读起来更像工作室的工程文件而非提示词记录。它以一行艺术指导开篇每个项目都在生成第一帧之前用一行话陈述其理念并嵌入反讽然后逐一走过模型选型、平台与本地的成本对比在 RTX 5090 与云端 B200 上每段片段、每个场景的成本,含渲染时间),以及一张逐镜表,把每张源图像与其完整的提示词和设置一一配对。
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig5-go-plastic.jpg" alt="NTU Index 屏幕上的 GO! PLASTIC" caption="GO! PLASTICHoe Jianwei。实验动画NTU Index 屏幕15 米 × 2 米新加坡。摄影Quek Jia Liang。" />
</Section>
<Section id="topic-6">
### Ina这些作品离开课堂之后去向何方
走上公共屏幕,进入有评审的国际竞赛。城市数字皮肤艺术节创办于 2023 年由中国美术学院雕塑与公共艺术学院发起并与柏林公共艺术实验室Public Art Lab Berlin、米兰 MEET 数字文化中心以及 NTU ADM 共同策展,背后是一个覆盖中国和欧洲、超过 29 所艺术院校的网络。
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig6-cdsa-awards.jpg" alt="西湖媒体立面上的 CDSA 艺术节获奖者" caption="CDSA 艺术节的获奖者、策展人与主办方。西湖媒体立面170 米 × 18 米中国杭州。摄影Limpid Art。亚洲最大的高清户外屏幕。" />
2024 年那一届覆盖 5 个国家 9 座城市的 11 块 LED 屏幕,触达观看量超过 1 亿次。20252026 届以"记忆共存"为主题,收到 200 多件国际投稿,由 16 位评委从中选出前 40 名。我策划新加坡单元,横跨 NTU Index 和 Ten Square 地标立面。一名在我们课堂上以 6K 构图的学生,就是在为那个平台构图。
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig7-crispr.jpg" alt="Ten Square 地标立面上的 Crispr" caption="CrisprLee Chaewon。实验动画Ten Square 地标立面21.2 米 × 14.4 米新加坡。摄影Quek Jia Liang。" />
NTU ADM 的学生早已在这一层级上获奖。在 CDSA 2025 上大多数最高奖项都花落这两门课程的学生金奖Sun Yutong*Echoes of Her*、银奖Tan Yu Yan Cheerie*Eternal Flux*、铜奖Shah Pranjal Kirti*Mumbai Miniatures*、商业奖Ong Sze Ching*Nuwa*以及创意奖Leah Chakola*Caravan of Memory*)。这两门课程还让 NTU 自 2023 年起成为唯一参与林茨 Ars Electronica 的新加坡校园伙伴,先是凭 *Butterfly's Dreams*2023主题"谁拥有真相?"),随后在 2025 年凭 *Beyond the Screen*——一场与中国美术学院和魏玛包豪斯大学联合举办的展览。
### Mark你在 DreamWorks 待了十年。为什么这个工具适合艺术专业的学生?
我出身于视觉特效。我在 DreamWorks 待了大约十年,之后是 Rhythm & Hues再之后是游戏行业和大型交互装置。我不是程序员所以我很喜欢 ComfyUI。
<Quote>我认识的每一个如今做图形的人都在用它,因为它太能适应各种需求了。有时我们只是把 Comfy 当作后端来用。大家都在这么做。</Quote>
我们在大学里的一件艺术装置中拥有了这块 15 米 × 2 米的大屏幕,它让我们得以探索媒体和各种不同的技法。我们发现学生的技术功底还不足以驾驭 TouchDesigner于是他们干脆开始做影片。后来我开始玩 AI现在一切都是 AI 了。我接下来最想要的,是为这些屏幕量身定制的模板。
以 *Echoes, Whispers and Memories* 为例,这是 Ina 和我共同创作的作品。我们并不用 Comfy 直接吐出成品插画。我们构建的工作流会不断重新组合图像,把它拆开再拼回去,让它在屏幕上演化,而这正是要点所在:熵、记忆、事物的瓦解与重构。然后我们把这些输出推入面向大空间的实时和投影系统,比如 Ars Electronica 的 Deep Space 8K 和米兰的 MEET。
<Figure src="https://media.comfy.org/website/customers/ina-conradi/fig8-echoes.jpg" alt="Ars Electronica Deep Space 8K 中的 Echoes, Whispers and Memories" caption="Echoes, Whispers and MemoriesMark Chavez 与 Ina Conradi。使用 ComfyUI 创作的 AI 生成沉浸式装置Deep Space 8KArs Electronica奥地利林茨。摄影Wolfgang Simlinger。" />
### 来自行业的信号
<Quote>我从那些找实习或工作的学生那里听说,对方问的第一个问题就是:"你会用 Comfy 吗?"因为他们想招懂这条管线的年轻人。</Quote>
</Section>
<Section id="topic-7" title="概览">
<AtAGlance rows={[
{ label: "院校", value: "南洋理工大学艺术、设计与媒体学院(新加坡)" },
{ label: "课程", value: "DM2012AI 生成艺术探索(本科)与 AP7055创意机器时代的艺术研究生自 2022 年起由 Ina Conradi 撰写并讲授;每学期约 30 名学生" },
{ label: "画布", value: "新加坡和中国的 6K 宽、8:1 LED 墙;校园里的 NTU Index 墙15 米 × 2 米5,888 × 768 像素)" },
{ label: "核心技术", value: "ComfyUI 构图,配合 Topaz 放大以输出超宽全景;带有每段片段成本和提示词表的制作日志" },
{ label: "Comfy 胜出的原因", value: "托管工具被锁定在 16:9构图后要把 1:8 全景放大到 8K 需要一条多模型管线;按座席按月续费不契合学期节奏" }
]} />
</Section>
<AuthorBio label="关于作者" people={[
{ name: "Ina Conradi", photo: "https://media.comfy.org/website/customers/ina-conradi/author-ina.jpg", bio: `Ina Conradi 是往返于新加坡和洛杉矶之间的艺术家与策展人。她是 NTU 艺术、设计与媒体学院(创立于 2005 年)的创院教员,自 2022 年起撰写并讲授该院的 AI 课程。她的影片 Moirai: Thread of Life 荣获 SIGGRAPH Asia 2023 计算机动画节的 Best in Show这是新加坡首次获此殊荣。` },
{ name: "Mark Chavez", photo: "https://media.comfy.org/website/customers/ina-conradi/author-mark.jpg", bio: `Mark Chavez 是动画师、导演,也是新加坡 NTU 艺术、设计与媒体学院的创院教员。在 DreamWorks Animation 工作十年、并在最初的 Rhythm & Hues Studios 从事视觉特效之后,他创建了 NTU 的数字动画方向2005并成立了一个由新加坡国家研究基金会和媒体发展局资助的动画研究智库。` }
]} />
<EducationCta />

View File

@@ -0,0 +1,138 @@
---
title: "为 AI 而建Kathy Smith 教授谈 USC 的拓展动画项目与 ComfyUI"
category: "创意校园展示"
description: "走进这个从第一天起就把 AI 纳入动画教学的实验性 USC MFA 项目,以及它孕育出的学生创作管线。"
cover: "https://media.comfy.org/website/customers/kathy-smith/cover.png"
order: 8
sections:
- id: topic-1
label: "项目"
- id: topic-2
label: "用 AI 教学"
- id: topic-3
label: "为何选择 ComfyUI"
- id: topic-4
label: "学生作品"
- id: topic-5
label: "概览"
- id: topic-6
label: "未来展望"
---
<Section id="topic-1">
### 你在 2022 年专门创办了拓展动画项目,从第一天起就把 AI 纳入课程。你看到了其他项目忽略的什么?
我们创办 Expanded Animation: Research and Practice就是要专注于创作过程并把 AI 作为动画师学习创作的一部分。最初的论点是AI 将重塑动画这一媒介,问题不在于是否教它,而在于如何把它嵌入课程,让学生把它当作创作过程的一部分来学习,而不是当作一门单独的技术专长。
USC 电影艺术学院早已拥有数十年的电影叙事传统。我们用 XA 所做的,是把 AI 放进这一传统之中。观念思考、叙事、电影史居于首位。AI 只是他们可用的众多工具之一与手绘、绘画、3D 和实拍素材并列。学生不是在一门课里学 AI、在另一门课里学动画。他们是并肩学习二者的。
进入这个项目的学生通常是自我筛选而来的。他们到来时就已在技术上娴熟,带着自己配备 GPU 的笔记本电脑。我们给他们的是叙事、电影史和观念框架。技术上的灵活敏捷则由他们带来。
<Quote>他们远远走在前面。在工作方式上,他们在技术上领先于教员,但在艺术上并非如此。而艺术,正是我们要交付给他们的。</Quote>
<Figure src="https://media.comfy.org/website/customers/kathy-smith/usc-campus.png" alt="USC 电影艺术学院" caption="USC 电影艺术学院。来源USC Today" />
</Section>
<Section id="topic-2">
### 你实际上是如何设计一份 AI 作业的?带我们走一遍。
在我的《动画、梦境与意识》课上我让学生记录自己的梦然后把梦作为素材来源。有的画有的写。梦成为提示词他们生成梦的图像与情感。我很喜欢算法里冒出六根手指和各种怪异现象的时刻。我们在梦中的人类感知常常也在做同样的事。因此AI 正与我们一同演化、一同做梦。
这种结构是刻意为之的。学生不是在让模型替他们完成作品而是把它当作创作过程的一层与手绘、绘画、3D 渲染和实拍素材并用。产出的作品是他们自己的,因为创作决策是他们自己的。工具只是给了他们抵达心中所想的新途径。
围绕 AI 存在一种恐惧,我理解它。此前有大量对艺术家作品的抓取,那场讨论是真实的,也需要时间来化解。但我从 1998 年起就在观念层面与 AI 打交道,我向学生描述数据集的方式是:它们是我们全部创造的一座宝库。它像是人类心智的集体无意识。艺术家一向从身边的一切汲取养分。
<Quote>真正重要的,是艺术家如何运用它,也就是*意图性*。</Quote>
</Section>
<Section id="topic-3">
### 为什么恰恰是 ComfyUI 契合你学生的工作方式?
在于它的节点式系统。用过 Houdini 的人在 Comfy 里会非常自在。你可以处理提示词,但它非常视觉化。这正是他们所习惯的。他们不是向一个黑箱索要输出,而是在构建一条工作流。
而且它安守本分。学生不是用 Comfy 去做"AI 艺术"。他们把 Comfy 当作一张节点图,与 Blender、手绘帧、绘画和实拍素材并用。它之所以契合是因为它不试图成为整条管线。它是一个仍以电影为核心的创作实践中的一个环节。
同样重要的是Comfy 是开放且可检视的。学生能看到模型在每一步做了什么,可以复刻一条工作流、换一个采样器、放入一个自定义节点,并把自己构建的东西分享给下一届。这更接近动画工作室传统一贯的行事方式:技艺被传递、被改进,而不是藏在付费墙之后。
他们还能在手头的任何硬件上工作:在家和移动时用 Comfy Cloud在个人笔记本电脑上用便携版做高端运算时用我办公室里的研究计算机。动画专业的学生不会为了一个毕业设计而固守一个格子间。他们在任何地方工作。
</Section>
<Section id="topic-4">
### 说说这个项目里产出的作品吧。
整届学生身上都能看到同一种规律AI 服务于电影叙事,而非取代它。三位学生向我们讲解了 Comfy 究竟如何嵌入他们的管线。
#### Sijia Zheng — Ori & Kiddo
<Figure src="https://media.comfy.org/website/customers/kathy-smith/ori-kiddo.png" alt="Sijia ZhengOri & Kiddo" />
**Comfy 让什么成为可能:**一种"其他 AI 工具根本做不出来"的油画笔触梦境质感,并借助 IP-Adapter 风格迁移和一个自定义 LoRA 在各镜头间保持一致。
*Ori & Kiddo* 讲述两个幽灵在宇宙消亡之后,去寻找人类旧日记忆、重新发现爱、并把宇宙逆转回生的故事。影片大部分是手绘 2D。Comfy 在梦境段落登场,幽灵 Kiddo 在梦中回到前世那里的质感必须与片中其他一切都不同。Sijia 先画出风格化的参考图,然后通过一条 IP-Adapter 工作流把它们作为视频片段的风格参考,产出长长的、油画笔触的段落。同样的控制也出现在 Sijia 出镜的镜头里:真实素材在 Comfy 中被遮罩处理用来更换发型、替换背景。对于必须锁定不变的质感Sijia 训练一个 LoRA 并在 Comfy 中运行它。
Sijia 在 2025 年初找到了 Comfy当时她正在寻找一个 Midjourney 和 DALL-E 都无法提供的风格迁移工具,并用它测试一种风格化的动画影片质感转换。
<Quote>它彻底震撼了我。大多数时候,我想我会站在别人的肩膀上。那些工作流已经相当惊艳,我会以它们为基础,再加上一些我想要的东西。</Quote>
自 *Ori & Kiddo* 以来Sijia 已把同样以 Comfy 为核心的工作流带入专业商业视频工作中,交付周期最短仅有四天。
#### Ion Yunyang Li — L1LY
<Figure src="https://media.comfy.org/website/customers/kathy-smith/l1ly.gif" alt="Ion Yunyang LiL1LY" />
**Comfy 让什么成为可能:**一条可复用的多步管线,把创作者带入一个照片级真实的世界,因为"你需要的不只是一串提示词"。
Ion 在 2025 年初通过生成式 AI 社区的教程自学了 ComfyUI并在一个关于身体与环境的项目中构建了他最具特色的 Comfy 作品:从 3D 模型静帧出发,把它们转成铅笔素描风格以免模型过度学习原始的 3D 美学,再从素描生成照片级真实的帧,构建角色 T-pose把自己合成进场景最后用一个视频模型让静帧动起来。
<Quote>你需要的不只是一串提示词。你需要许多不同的设置,而每次都重做这些设置非常困难。</Quote>
与这条管线同样让他看重的,是它能在哪里运行:同一套 Comfy 配置可以在他学校格子间的工作站、公寓笔记本电脑的远程会话,以及完全基于云端的实例之间切换,视他身在何处而定。
#### Sihan Wu — Scary Coaster
<Figure src="https://media.comfy.org/website/customers/kathy-smith/scary-coaster.gif" alt="Sihan WuScary Coaster" />
**Comfy 让什么成为可能:**在他人生第一个 AI 项目里,约 100 张手绘关键帧被同一条工作流贯穿始终,使一部两到三分钟的影片在视觉上保持一致。
*Scary Coaster*2024 年 12 月)是 Sihan 第一次用 AI 完成的项目。Sihan 本科读的是数字媒体和游戏开发,进入 Smith 教授的拓展动画课后,他想要一种比现有的纯提示词工具更可控的东西。他构建的工作流是:手绘约 100 张粗略关键帧,让它们经过 Comfy 找到一种风格化的中式恐怖质感,挑出最满意的,然后生成中间帧以产出完整段落。
<Quote>我想要一条更可控的流程。我不想只用提示词去生成随机图像。我只用一条工作流就做出整整两三分钟,并且能让一切看起来非常一致。</Quote>
Sihan 坦言上手很陡:他从 ComfyUI 官方 GitHub 的工作流中学习,把它们组合起来,一路上还要调试 Python 环境。他的诉求很具体:一套从入门到进阶的官方教程系列。而他对 AI 下一步该走向何方的看法同样具体:把它对准"非常耗时、却又没那么有创意的过程,比如制作中间帧",把创作决策留给艺术家。
</Section>
<Section id="topic-5" title="概览">
<AtAGlance rows={[
{ label: "项目", value: "Expanded Animation: Research + PracticeXAUSC 电影艺术学院" },
{ label: "创立", value: "2022 年AI 从第一天起就嵌入 MFA 课程" },
{ label: "配置", value: "学生自己的 GPU 笔记本电脑 + Comfy Cloud + 实验室研究机器" },
{ label: "核心技术", value: "IP-Adapter 风格迁移、自定义 LoRA、遮罩合成、关键帧到中间帧的管线" },
{ label: "成果", value: "来自 Sihan、Ion、Sijia 的精彩学生作品" }
]} />
</Section>
<Section id="topic-6">
### 对于这一切的走向,什么最让你兴奋?
我有一个理念每个人都是艺术家。他们只是忘了自己是艺术家。创造力驱动着一切而我们如今得到的工具让更多人有可能在自身之内发现那份潜能。ComfyUI 因为是节点式的、视觉化的、开放的,给了非程序员一条诚实面对模型运作方式的前行之路。它不假装 AI 在施展什么魔法,而是把每一步发生的事都展示给艺术家看。
人类生活的两项基本权利是健康和教育。Comfy 在教育这一侧所做的工作,触及了某种不可或缺的东西。从 XA 走出来的学生,已经在把这项工作朝着项目未曾预料的方向延伸,而下一代教育者和学生也会继续这样做。
<Quote>每个人都是艺术家。他们只是忘了自己是艺术家。</Quote>
</Section>
<AuthorBio people={[{ name: "Kathy Smith", photo: "https://media.comfy.org/website/customers/kathy-smith/kathy-smith.jpg", bio: `Kathy Smith 是 USC 电影艺术学院的电影艺术教授,也是 Expanded Animation: Research + PracticeXA的首任主任20222023。这个实验性的 MFA 项目由她在 2022 年参与创办,旨在从学位的第一天起就把 AI 融入动画教学。至今她是 USC 合并后动画项目在任时间最长的系主任,并自 1998 年起就在自己的创作实践中探索 AI 的种种概念。` }]} />
<EducationCta />

View File

@@ -0,0 +1,56 @@
---
title: "Comfy 与 UAL 创意计算研究院宣布建立创意校园合作伙伴关系"
category: "创意校园合作伙伴"
description: "Comfy 宣布建立创意校园合作伙伴关系,为 UAL CCI 的硕士、博士及行业项目的教学与研究提供支持"
cover: "https://media.comfy.org/website/customers/ual-cci/cover.png"
order: 9
sections:
- id: topic-1
label: "简介"
- id: topic-2
label: "CCI 在做什么"
- id: topic-3
label: "合作伙伴关系"
- id: topic-4
label: "关于 CCI"
---
<Section id="topic-1">
Comfy OrgComfyUI 背后的团队ComfyUI 是面向生成式 AI 的开源节点式界面与伦敦艺术大学创意计算研究院Creative Computing InstituteCCI今日宣布建立创意校园Creative Campus合作伙伴关系使 CCI 成为 [Comfy 教育计划](https://comfy.org/education) 的创始合作伙伴。
</Section>
<Section id="topic-2">
CCI 已在研究院的各个层级运行 ComfyUI。在"面向创作者的应用机器学习"硕士课程上,学生构建图像、视频、音频和文本工作流,训练自己的模型,并搭建交互式管线。博士研究人员用 Comfy 进行微调、自定义数据集和自定义节点开发。研究院还在行业培训中使用 ComfyUI其节点式界面为非技术背景的合作者提供了一条单靠代码无法企及的、通往生成式 AI 的路径。
<Quote name="Mick Grierson 教授研究负责人UAL 创意计算研究院">ComfyUI 已经成为我们教学、研究以及与行业协作方式的一部分。它是少数几个能让学生构建的工作流可移植、可检视、可复刻的生成式 AI 环境之一,而这种开源根基,正是大学应当用来教学的东西。</Quote>
</Section>
<Section id="topic-3">
通过此次合作CCI 的教育者和学生将获得带有集中计费与管理的课堂许可、教育折扣、对即将推出的团队功能的抢先体验、一个能得到 Comfy 团队直接支持的专属教育者社区,以及在塑造教育计划未来时的一份话语权。
创意校园合作伙伴关系是该计划中最深入的一层:一种直接、持续的协作,院校与 Comfy 团队携手,在教学、研究和行业培训中全面推广 ComfyUI。
<Quote name="Comfy 团队">CCI 就是我们希望每一所创意校园都能效仿的范例ComfyUI 同时出现在硕士课堂、博士研究和行业协作之中。作为我们的首个创意校园合作伙伴,他们正帮助我们设计一套真正契合大学运作方式的教育计划。</Quote>
<Figure src="https://media.comfy.org/website/customers/ual-cci/cci-camberwell.jpg" alt="UAL 创意计算研究院校区" caption="创意计算研究院校区。摄影Ana Escobar由 UAL 提供。" />
该研究院正牵头一项 150 万英镑的公共资助研究计划,为英国创意产业开发符合版权规范的视听基础模型。项目汇聚声音、图像和人工智能领域的专长,正在构建开放工具和负责任的 AI 国家基础设施,旨在支持英国创意生产、研究以及全行业的实验。
这项研究的成果将探索通过 ComfyUI 等开放的节点式工具进行更广泛的传播与采用,以支持围绕新兴多模态 AI 系统的实验、工作流和协作。
UAL CCI 加入了在 Comfy 教育计划发布之际亮相的创始教育者与院校群体,与之并列的还有 CCI 联合创始人 Phoenix Perry 博士等研究者——Comfy 作为行业合作伙伴,为她的 Antigravity Machine 项目提供支持。
</Section>
<Section id="topic-4" title="关于 UAL 创意计算研究院">
伦敦艺术大学的创意计算研究院将计算应用于创造力与社会影响,立足于计算技术与创意实践的交汇处,在开展研究与行业协作的同时,教授本科、硕士和博士学生。
</Section>
<EducationCta />

View File

@@ -0,0 +1,103 @@
---
title: "拓展我艺术的工具Xindi Zhang 入围奥斯卡的毕业作品,用 ComfyUI 打造"
category: "创意校园展示"
description: "一部 USC 拓展动画毕业作品如何成为学生奥斯卡奖得主、奥斯卡短名单入围作,并帮助拿下亚马逊的工作——而风格指南正是艺术家自己的插画。"
cover: "https://media.comfy.org/website/customers/xindi-zhang/cover.webp"
order: 5
sections:
- id: topic-1
label: "简介"
- id: topic-2
label: "为何选择 ComfyUI"
- id: topic-3
label: "制作管线"
- id: topic-4
label: "概览"
- id: topic-5
label: "未来展望"
---
<Section id="topic-1">
<Embed src="https://player.vimeo.com/video/1131160045" title="The Song of Drifters作者 Xindi Zhang" />
*选自 The Song of Drifters。影片图像Xindi Zhang。*
### 说说 The Song of Drifters 吧。它讲的是什么,又是从哪里开始的?
The Song of Drifters 是一部纪录动画,讲述那些困在"离开"与"归来"之间的人——漂泊于陌生城市的游子,守着一个遥不可及的故乡的记忆,寻找归属感。片名直接译自一首关于母亲对离乡孩子之爱的中国古诗。我的版本采取了相反的视角,从孩子的角度出发。
我用 ComfyUI 完成了这部影片。刚开始时,我并不是想展示 AI 能做什么。我想证明的几乎是相反的东西。
<Quote>它始于对一种刻板印象的挑战:认为 AI 生成的作品千篇一律、廉价。我想证明AI 可以是个人视野的放大器,而不是它的替代品。</Quote>
</Section>
<Section id="topic-2">
### 你是从插画而非工程走到这一步的。你是怎么进入 ComfyUI 的?
我一开始是插画师。我在罗德岛设计学院取得插画 BFA 学位之后做游戏概念艺术家在那里学会了着色器、Unity 和 Unreal。那段技术经历让我上手新工具很快。后来我去 USC 电影艺术学院攻读拓展动画 MFA师从 Kathy Smith 教授。
到毕业创作那一年,我已经从 Stable Diffusion 的标准界面转向了 ComfyUI因为我的思维是节点式结构的我想掌控每一步。大多数 AI 工具只需一键:你写提示词,一点,就得到一个结果。那不是我想要的。
<Quote>我想掌控这个过程,而过程甚至比结果本身更重要。对我这样的艺术家而言,我不想让任何东西自动化。我想参与设计工作流的每一个环节。那才是其中的乐趣所在。</Quote>
</Section>
<Section id="topic-3">
### 带我们走一遍管线。你实际上在给模型喂什么?
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/balloon-workflow.png" alt="Xindi 用于气球段落的 ComfyUI 工作流">Xindi 用于气球段落的 ComfyUI 工作流。来源:[xindizhangart.com](https://xindizhangart.com)。</Figure>
我的核心技术是在 Stable Diffusion 1.5 中做风格迁移,由 IP-Adapter 和 ControlNet 驱动。最要紧的是我喂给它的东西:我自己的作品。基础素材是我用 iPhone 15 Pro 拍摄的实拍素材,以及我在 Blender 中构建的 3D 动画。AI 重新风格化的是我早已做好的影像,而不是凭空发明它。
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/film-still.jpg" alt="The Song of Drifters 的风格指南定帧">The Song of Drifters 的风格指南定帧。来源:[xindizhangart.com](https://xindizhangart.com)。</Figure>
<Quote>与大多数使用模型里其他艺术家作品的 AI 生成视频不同,我用自己的插画作为风格指南。</Quote>
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/style-transfer-workflow.json" label="在 ComfyUI 上下载 Xindi 的风格迁移工作流json" />
我还在自己的视频上训练了自定义 LoRA素材是我住过的那些城市的影像。拍摄这些素材成了纪录过程中至关重要的一环。漫步在我曾经生活过的街道让我重新与那些城市建立起联系。它们大多没有出现在最终剪辑里但作为训练数据活在画面之中。这条混合管线让渲染最终质感更高效也为构思省下了更多时间。
在梦境段落里,我把 3D 动画与 AI 变形结合起来,从抽象过渡到具体,去模拟半梦半醒的感觉。
<Video src="https://media.comfy.org/website/customers/xindi-zhang/bts-clip.mp4" poster="https://media.comfy.org/website/customers/xindi-zhang/bts-poster.jpg" caption="幕后花絮片段AI 变形。来源Xindi Zhang。" />
<Download href="https://media.comfy.org/website/customers/xindi-zhang/workflows/morphing-workflow.json" label="在 ComfyUI 上下载 Xindi 的 AI 变形工作流json" />
</Section>
<Section id="topic-4" title="概览">
<AtAGlance rows={[
{ label: "项目", value: "USC 电影艺术学院 — MFA 拓展动画(毕业创作)" },
{ label: "基础素材", value: "iPhone 15 Pro 实拍;她本人的 Blender 3D 动画" },
{ label: "核心技术", value: "在 ComfyUI 中,通过 IP-Adapter + ControlNet 在 SD 1.5 里做风格迁移" },
{ label: "风格来源", value: "她本人的插画 + 在她本人城市影像上训练的自定义 LoRA" },
{ label: "后期收尾", value: "在 After Effects 中做深度、遮罩和淡入淡出处理;大量合成" },
{ label: "成果", value: "2025 学生奥斯卡奖金奖;第 98 届奥斯卡短名单;亚马逊 AI Studio 的 AI Creative 职位" }
]} />
</Section>
<Section id="topic-5">
### 这部影片赢得了学生奥斯卡奖金奖,并入围了奥斯卡短名单。接下来呢?
我拍这部影片出于创作而非职业的理由。老实说,我根本没指望它会与一份工作产生任何联系。后来它赢得了 2025 学生奥斯卡奖金奖并入围奥斯卡短名单,然后电话就开始打进来了。
<Figure src="https://media.comfy.org/website/customers/xindi-zhang/awards.png" alt="Xindi Zhang 在 2025 学生奥斯卡奖颁奖现场" caption="Xindi Zhang 在 2025 学生奥斯卡奖颁奖现场。来源Oscars Press Office。" />
人们想要的是这种结合:一个既懂传统手艺、又懂 AI 工具的人。我现在是亚马逊 AI Studio 的 AI Creative负责构建定制的生产管线。我在整个行业里看到同样的需求ComfyUI 经验开始作为一项要求出现在各大工作室和设计机构的招聘启事中。
<Quote>它不是偷走我艺术的工具。它是拓展我艺术的工具。</Quote>
我给其他学生的建议其实与软件无关。AI 只是又一个用来传达想法的工具,但没有什么比故事本身更重要。如果你用 AI就有意识地去用。你越是理解它就越有自由去创作真正属于你自己的作品。
</Section>
<AuthorBio people={[{ name: "Xindi Zhang", photo: "https://media.comfy.org/website/customers/xindi-zhang/profile.jpg", bio: `Xindi Zhang 是一位中国动画导演与视觉艺术家RISD 插画 BFA2020USC 拓展动画 MFA2025。The Song of Drifters 荣获 2025 学生奥斯卡奖金奖,并入围第 98 届奥斯卡短名单。她现任亚马逊 AI Studio 的 AI Creative曾与索尼音乐的沉浸式工作室合作目前在南佛罗里达大学任教。` }]} />
<EducationCta />

View File

@@ -0,0 +1,178 @@
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我们会为您准备好所需的资料。'
}
},
{
id: 'full-terms',
question: {
en: 'Where can I read the full terms?',
'zh-CN': '我在哪里可以阅读完整条款?'
},
answer: {
en: '<a href="https://www.notion.so/comfy-org/Comfy-for-Education-Terms-3766d73d365081b78d7ac3fb6dd7f61f?source=copy_link" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Comfy for Education Terms</a>. You\'re on a standard Comfy Cloud plan at an education rate, so the <a href="https://comfy.org/terms-of-service" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Comfy Terms of Service</a> and <a href="https://comfy.org/privacy-policy" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Privacy Policy</a> apply too.',
'zh-CN':
'<a href="https://www.notion.so/comfy-org/Comfy-for-Education-Terms-3766d73d365081b78d7ac3fb6dd7f61f?source=copy_link" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Comfy 教育版条款</a>。您使用的是按教育优惠价提供的标准 Comfy Cloud 计划,因此 <a href="https://comfy.org/terms-of-service" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Comfy 服务条款</a>和<a href="https://comfy.org/privacy-policy" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">隐私政策</a>也同样适用。'
}
}
] 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

@@ -129,6 +129,11 @@ export function getMainNavigation(locale: Locale): NavItem[] {
label: t('nav.learning', locale),
href: routes.learning,
badge: 'new'
},
{
label: t('nav.education', locale),
href: routes.education,
badge: 'new'
}
]
},

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,46 @@
import { describe, expect, it } from 'vitest'
import type { PricingPlan } from './pricingPlans'
import { planFeatures, pricingPlans } from './pricingPlans'
const eduPlan = pricingPlans.find((plan) => plan.eduPriceKey)!
const planWithoutEduPricing: PricingPlan = {
id: 'no-edu',
labelKey: 'pricing.plan.free.label',
ctaKey: 'pricing.plan.free.cta',
ctaHref: () => '',
features: [{ text: 'pricing.feature.addCredits' }]
}
describe('planFeatures', () => {
it('prepends the monthly savings row for edu-priced plans on monthly billing', () => {
const result = planFeatures(eduPlan, true, 'monthly')
expect(result[0]).toEqual({
text: 'pricing.feature.educationalSavings',
highlight: true
})
expect(result.slice(1)).toEqual(eduPlan.features)
})
it('prepends the yearly savings row for edu-priced plans on yearly billing', () => {
const result = planFeatures(eduPlan, true, 'yearly')
expect(result[0]).toEqual({
text: 'pricing.feature.educationalSavingsYearly',
highlight: true
})
expect(result.slice(1)).toEqual(eduPlan.features)
})
it('leaves features unchanged outside education mode', () => {
expect(planFeatures(eduPlan, false, 'yearly')).toBe(eduPlan.features)
})
it('does not add the savings row to plans without education pricing', () => {
expect(planFeatures(planWithoutEduPricing, true, 'monthly')).toBe(
planWithoutEduPricing.features
)
})
})

View File

@@ -0,0 +1,151 @@
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'
export interface PlanFeature {
text: TranslationKey
status?: PlanFeatureStatus
highlight?: boolean
}
export interface PlanFeatureGroup {
titleKey?: TranslationKey
features: PlanFeature[]
}
export interface PricingPlan {
id: string
labelKey: TranslationKey
priceKey?: TranslationKey
yearlyPriceKey?: TranslationKey
yearlyTotalKey?: TranslationKey
eduPriceKey?: TranslationKey
eduYearlyPriceKey?: TranslationKey
eduYearlyTotalKey?: 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',
eduPriceKey: 'pricing.plan.standard.eduPrice',
eduYearlyPriceKey: 'pricing.plan.standard.eduYearlyPrice',
eduYearlyTotalKey: 'pricing.plan.standard.eduYearlyTotal',
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',
eduPriceKey: 'pricing.plan.creator.eduPrice',
eduYearlyPriceKey: 'pricing.plan.creator.eduYearlyPrice',
eduYearlyTotalKey: 'pricing.plan.creator.eduYearlyTotal',
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',
eduPriceKey: 'pricing.plan.pro.eduPrice',
eduYearlyPriceKey: 'pricing.plan.pro.eduYearlyPrice',
eduYearlyTotalKey: 'pricing.plan.pro.eduYearlyTotal',
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
function eduSavingsFeature(cycle: BillingCycle): PlanFeature {
return {
text:
cycle === 'yearly'
? 'pricing.feature.educationalSavingsYearly'
: 'pricing.feature.educationalSavings',
highlight: true
}
}
// In education mode, plans with education pricing lead with the highlighted
// savings row (whose discount tracks the billing cycle); every other case
// keeps the plan's own feature list unchanged.
export function planFeatures(
plan: PricingPlan,
education: boolean,
cycle: BillingCycle
): PlanFeature[] {
return education && plan.eduPriceKey
? [eduSavingsFeature(cycle), ...plan.features]
: plan.features
}

View File

@@ -0,0 +1,64 @@
export interface TeamCreditTier {
credits: number
basePrice: number
monthlyPrice: number
yearlyPrice: number
// Education prices off basePrice: monthly 5/7.5/10/12.5/15%,
// yearly 5/10/15/20/25% per tier.
eduMonthlyPrice: number
eduYearlyPrice: number
videos: number
}
export const teamCreditTiers: readonly TeamCreditTier[] = [
{
credits: 42200,
basePrice: 200,
monthlyPrice: 200,
yearlyPrice: 200,
eduMonthlyPrice: 190,
eduYearlyPrice: 190,
videos: 3830
},
{
credits: 84400,
basePrice: 400,
monthlyPrice: 390,
yearlyPrice: 380,
eduMonthlyPrice: 370,
eduYearlyPrice: 360,
videos: 7660
},
{
credits: 147700,
basePrice: 700,
monthlyPrice: 665,
yearlyPrice: 630,
eduMonthlyPrice: 630,
eduYearlyPrice: 595,
videos: 13405
},
{
credits: 295400,
basePrice: 1400,
monthlyPrice: 1295,
yearlyPrice: 1190,
eduMonthlyPrice: 1225,
eduYearlyPrice: 1120,
videos: 26810
},
{
credits: 527500,
basePrice: 2500,
monthlyPrice: 2250,
yearlyPrice: 2000,
eduMonthlyPrice: 2125,
eduYearlyPrice: 1875,
videos: 47830
}
]
export function formatTeamCreditsShort(n: number): string {
const k = n / 1000
return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`
}

View File

@@ -39,6 +39,32 @@ const translations = {
'zh-CN': '观看演示'
},
// Education CTA (customer story block)
'educationCta.heading': {
en: 'Teaching with ComfyUI?',
'zh-CN': '正在使用 ComfyUI 教学?'
},
'educationCta.body': {
en: 'The Comfy Education Program is live: educational pricing, classroom cloud accounts on one invoice,',
'zh-CN': 'Comfy 教育计划现已上线:教育定价、一张发票管理课堂云账户,'
},
'educationCta.exploreLink': {
en: 'Explore the Education Program',
'zh-CN': '了解教育计划'
},
'educationCta.or': {
en: 'or',
'zh-CN': '或'
},
'educationCta.applyLink': {
en: 'apply to be a part of the Creative Campus program',
'zh-CN': '申请加入 Creative Campus 计划'
},
'educationCta.tail': {
en: "if you're interested in exploring a deeper partnership with Comfy.",
'zh-CN': '如果你有兴趣与 Comfy 建立更深入的合作。'
},
// HeroSection
'hero.title': {
en: 'Professional Control\nof Visual AI',
@@ -1069,6 +1095,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'
@@ -1109,8 +1140,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?',
@@ -1135,18 +1167,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?',
@@ -1158,76 +1190,122 @@ 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.subtitle.edu': {
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': '按月' },
// Education-only monthly toggle label; zh-CN drafted, pending native review.
'pricing.period.monthly.edu': {
en: 'Monthly (Up to 15% off)',
'zh-CN': '按月(最高 15% 优惠)'
},
'pricing.period.yearly': {
en: 'Yearly (Up to 20% off)',
'zh-CN': '按年(最高 20% 优惠)'
},
// Education-only yearly toggle label; zh-CN drafted, pending native review.
'pricing.period.yearly.edu': {
en: 'Yearly (Up to 25% off)',
'zh-CN': '按年(最高 25% 优惠)'
},
'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}'
},
// Team-card education saving label; zh-CN drafted, pending native review.
'pricing.team.educationalSaving': {
en: 'Educational saving ~{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': '每月积分' },
// Education-only highlighted feature rows; zh-CN drafted, pending native review.
'pricing.feature.educationalSavings': {
en: 'Educational savings 10% off',
'zh-CN': '教育优惠 立减 10%'
},
'pricing.feature.educationalSavingsYearly': {
en: 'Educational savings 25% off',
'zh-CN': '教育优惠 立减 25%'
},
'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': '免费开始' },
@@ -1241,112 +1319,126 @@ 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' },
// Education prices (10% off monthly, 25% off yearly, off the $20 list price).
'pricing.plan.standard.eduPrice': { en: '$18', 'zh-CN': '$18' },
'pricing.plan.standard.eduYearlyPrice': { en: '$15', 'zh-CN': '$15' },
'pricing.plan.standard.eduYearlyTotal': { en: '$180', 'zh-CN': '$180' },
'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' },
// Education prices (10% off monthly, 25% off yearly, off the $35 list price).
'pricing.plan.creator.eduPrice': { en: '$31.50', 'zh-CN': '$31.50' },
'pricing.plan.creator.eduYearlyPrice': { en: '$26.25', 'zh-CN': '$26.25' },
'pricing.plan.creator.eduYearlyTotal': { en: '$315', 'zh-CN': '$315' },
'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' },
// Education prices (10% off monthly, 25% off yearly, off the $100 list price).
'pricing.plan.pro.eduPrice': { en: '$90', 'zh-CN': '$90' },
'pricing.plan.pro.eduYearlyPrice': { en: '$75', 'zh-CN': '$75' },
'pricing.plan.pro.eduYearlyTotal': { en: '$900', 'zh-CN': '$900' },
'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 的团队。'
en: 'Need more members? Looking for more flexibility or custom features?',
'zh-CN': '需要更多成员?想要更多灵活性或定制功能?'
},
'pricing.enterprise.cta': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
'pricing.enterprise.featureIntro': {
en: 'Everything in Pro, plus:',
'zh-CN': '包含专业版全部能力,另加:'
'pricing.enterprise.cta': { en: 'Contact Us', 'zh-CN': '联系我们' },
// Education re-skin of the enterprise band; zh-CN drafted, pending native review.
'pricing.creativeCampus.label': {
en: 'Creative Campus',
'zh-CN': '创意校园'
},
'pricing.creativeCampus.description': {
en: 'Partner with us on custom features, dedicated support, course accreditation, and a deeper campus partnership.',
'zh-CN':
'与我们合作,共同打造定制功能、专属支持、课程认证以及更深入的校园合作。'
},
// Student Ambassador interest band (education only); zh-CN drafted, pending review.
'pricing.studentAmbassador.comingSoon': {
en: 'Coming Soon',
'zh-CN': '即将推出'
},
'pricing.studentAmbassador.label': {
en: 'Student Ambassador',
'zh-CN': '学生大使'
},
'pricing.studentAmbassador.description': {
en: 'For students who want to bring Comfy to their campus.',
'zh-CN': '面向希望将 Comfy 带到校园的学生。'
},
'pricing.studentAmbassador.cta': {
en: 'Register Interest',
'zh-CN': '登记意向'
},
'pricing.enterprise.feature1': {
en: 'Annual commitments with bulk pricing and custom compute packages',
@@ -1375,6 +1467,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': '机器配置'
@@ -1388,9 +1484,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',
@@ -1415,9 +1511,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',
@@ -1434,16 +1530,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',
@@ -1463,23 +1559,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': '暂停' },
@@ -2218,6 +2310,7 @@ const translations = {
'nav.x': { en: 'X', 'zh-CN': 'X' },
'nav.instagram': { en: 'Instagram', 'zh-CN': 'Instagram' },
'nav.affiliates': { en: 'Affiliates', 'zh-CN': '联盟计划' },
'nav.education': { en: 'Education', 'zh-CN': '教育' },
'nav.contact': { en: 'Contact', 'zh-CN': '联系我们' },
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
@@ -4078,6 +4171,14 @@ const translations = {
en: 'VIEW ARTICLE',
'zh-CN': '查看文章'
},
'carousel.previous': {
en: 'Previous',
'zh-CN': '上一条'
},
'carousel.next': {
en: 'Next',
'zh-CN': '下一条'
},
'customers.feedback.quote1': {
en: 'ComfyUI is so important to us because it allows us to know that we always play on the bleeding edge. Not only because of the technology itself, but because of the entire community behind it. The community is what makes it so special.',
'zh-CN':
@@ -4384,6 +4485,84 @@ const translations = {
'zh-CN': '阅读联盟计划条款'
},
// Education page (/education) — 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 ComfyUI into your classroom — sign up with your academic email for discounted access. Scroll down to see how others use it.',
'zh-CN':
'将 ComfyUI 带入您的课堂 — 使用您的学术邮箱注册即可享受优惠。向下滚动,了解其他人如何使用它。'
},
'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 生成的发光树木艺术作品'
},
// EducationCustomerStoriesSection
'education.customerStories.heading': {
en: 'Educators and students using ComfyUI',
'zh-CN': '使用 ComfyUI 的教育者与学生'
},
'education.customerStories.subheading': {
en: 'See how USC, CMU, and UAL bring ComfyUI into the classroom',
'zh-CN': '看看 USC、CMU 和 UAL 如何将 ComfyUI 带入课堂'
},
// 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,7 +1,8 @@
---
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'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
@@ -37,6 +38,7 @@ const productId = jsonLdId(url, 'product')
}),
]}
>
<PriceSection client:load />
<PricingSection client:load />
<WhatsIncludedSection />
<FAQSection client:visible />
</BaseLayout>

View File

@@ -5,13 +5,39 @@ import FeedbackSection from '../components/customers/FeedbackSection.vue'
import HeroSection from '../components/customers/HeroSection.vue'
import StorySection from '../components/customers/StorySection.vue'
import VideoSection from '../components/customers/VideoSection.vue'
import { t } from '../i18n/translations'
import { toCardProps } from '../utils/customers'
import { loadStories } from '../utils/loadStories'
import { absoluteUrl, itemListNode, jsonLdId, pageContext } from '../utils/jsonLd'
const stories = (await loadStories('en')).map(toCardProps)
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale
)
const collectionLabel = t('nav.customerStories', locale)
const storyList = itemListNode(
url,
collectionLabel,
stories.map((story) => ({
url: absoluteUrl(Astro.site, `/customers/${story.slug}`),
name: story.title
}))
)
---
<BaseLayout title="Customer Stories - Comfy">
<BaseLayout
title="Customer Stories - Comfy"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: collectionLabel }
]}
extraJsonLd={[storyList]}
>
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -3,8 +3,15 @@ import CustomerArticle from '../../components/customers/CustomerArticle.astro'
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { t } from '../../i18n/translations'
import { nextStory, storySlug } from '../../utils/customers'
import { loadStories } from '../../utils/loadStories'
import {
absoluteUrl,
articleNode,
jsonLdId,
pageContext
} from '../../utils/jsonLd'
export async function getStaticPaths() {
const stories = await loadStories('en')
@@ -15,9 +22,37 @@ export async function getStaticPaths() {
}
const { entry, next } = Astro.props
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale
)
const article = articleNode({
siteUrl,
pageUrl: url,
title: entry.data.title,
description: entry.data.description,
imageUrl: entry.data.cover,
locale
})
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout
title={`${entry.data.title} - Comfy`}
description={entry.data.description}
ogImage={entry.data.cover}
mainEntityId={jsonLdId(url, 'article')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('nav.customerStories', locale),
url: absoluteUrl(Astro.site, '/customers')
},
{ name: entry.data.title }
]}
extraJsonLd={[article]}
>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -0,0 +1,77 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import PricingSection from '../components/pricing/PricingSection.vue'
import CtaSection from '../templates/education/CtaSection.vue'
import CustomerStoriesSection from '../templates/education/CustomerStoriesSection.vue'
import FAQSection from '../templates/education/FAQSection.vue'
import HeroSection from '../templates/education/HeroSection.vue'
import HowItWorksSection from '../templates/education/HowItWorksSection.vue'
import { educationOffers } from '../config/pricing'
import { educationFaqs } from '../data/educationFaq'
import { t } from '../i18n/translations'
import { isEducationStory, toCardProps } from '../utils/customers'
import {
absoluteUrl,
faqPageNode,
jsonLdId,
pageContext,
productNode
} from '../utils/jsonLd'
import { loadStories } from '../utils/loadStories'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale
)
// Creative Campus stories power the education carousel.
const stories = (await loadStories(locale))
.map(toCardProps)
.filter((story) => isEducationStory(story.slug))
// The discounted plans and the FAQ are the page's markup-worthy content, both
// linked into the single site-wide graph via BaseLayout's extraJsonLd.
const productId = jsonLdId(url, 'product')
const faqs = educationFaqs.map((faq) => ({
question: faq.question[locale],
answer: faq.answer[locale]
}))
---
<BaseLayout
title={t('education.page.title', locale)}
description={t('education.page.description', locale)}
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('nav.education', locale) }
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy for Education',
url,
offers: educationOffers(locale)
}),
faqPageNode(url, faqs)
]}
>
<HeroSection client:load />
<PricingSection
client:visible
locale={locale}
education
headingLevel="h2"
id="plans"
/>
<HowItWorksSection />
{
stories.length > 0 && (
<CustomerStoriesSection client:visible stories={stories} locale={locale} />
)
}
<FAQSection client:visible />
<CtaSection />
</BaseLayout>

View File

@@ -1,7 +1,8 @@
---
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'
import { pricingOffers } from '../../../config/pricing'
import { t } from '../../../i18n/translations'
import {
@@ -40,6 +41,7 @@ const productId = jsonLdId(url, 'product')
}),
]}
>
<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

@@ -5,13 +5,47 @@ import FeedbackSection from '../../components/customers/FeedbackSection.vue'
import HeroSection from '../../components/customers/HeroSection.vue'
import StorySection from '../../components/customers/StorySection.vue'
import VideoSection from '../../components/customers/VideoSection.vue'
import { t } from '../../i18n/translations'
import { toCardProps } from '../../utils/customers'
import { loadStories } from '../../utils/loadStories'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext
} from '../../utils/jsonLd'
const stories = (await loadStories('zh-CN')).map(toCardProps)
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale
)
const collectionLabel = t('nav.customerStories', locale)
const storyList = itemListNode(
url,
collectionLabel,
stories.map((story) => ({
url: absoluteUrl(Astro.site, `/zh-CN/customers/${story.slug}`),
name: story.title
}))
)
---
<BaseLayout title="客户故事 - Comfy">
<BaseLayout
title="客户故事 - Comfy"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN')
},
{ name: collectionLabel }
]}
extraJsonLd={[storyList]}
>
<HeroSection locale="zh-CN" client:load />
<StorySection stories={stories} locale="zh-CN" />
<FeedbackSection locale="zh-CN" client:load />

View File

@@ -3,8 +3,15 @@ import CustomerArticle from '../../../components/customers/CustomerArticle.astro
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { nextStory, storySlug } from '../../../utils/customers'
import { loadStories } from '../../../utils/loadStories'
import {
absoluteUrl,
articleNode,
jsonLdId,
pageContext
} from '../../../utils/jsonLd'
export async function getStaticPaths() {
const stories = await loadStories('zh-CN')
@@ -15,9 +22,40 @@ export async function getStaticPaths() {
}
const { entry, next } = Astro.props
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale
)
const article = articleNode({
siteUrl,
pageUrl: url,
title: entry.data.title,
description: entry.data.description,
imageUrl: entry.data.cover,
locale
})
---
<BaseLayout title={`${entry.data.title} - Comfy`}>
<BaseLayout
title={`${entry.data.title} - Comfy`}
description={entry.data.description}
ogImage={entry.data.cover}
mainEntityId={jsonLdId(url, 'article')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN')
},
{
name: t('nav.customerStories', locale),
url: absoluteUrl(Astro.site, '/zh-CN/customers')
},
{ name: entry.data.title }
]}
extraJsonLd={[article]}
>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -0,0 +1,77 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PricingSection from '../../components/pricing/PricingSection.vue'
import CtaSection from '../../templates/education/CtaSection.vue'
import CustomerStoriesSection from '../../templates/education/CustomerStoriesSection.vue'
import FAQSection from '../../templates/education/FAQSection.vue'
import HeroSection from '../../templates/education/HeroSection.vue'
import HowItWorksSection from '../../templates/education/HowItWorksSection.vue'
import { educationOffers } from '../../config/pricing'
import { educationFaqs } from '../../data/educationFaq'
import { t } from '../../i18n/translations'
import { isEducationStory, toCardProps } from '../../utils/customers'
import {
absoluteUrl,
faqPageNode,
jsonLdId,
pageContext,
productNode
} from '../../utils/jsonLd'
import { loadStories } from '../../utils/loadStories'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale
)
// Creative Campus stories power the education carousel.
const stories = (await loadStories(locale))
.map(toCardProps)
.filter((story) => isEducationStory(story.slug))
// The discounted plans and the FAQ are the page's markup-worthy content, both
// linked into the single site-wide graph via BaseLayout's extraJsonLd.
const productId = jsonLdId(url, 'product')
const faqs = educationFaqs.map((faq) => ({
question: faq.question[locale],
answer: faq.answer[locale]
}))
---
<BaseLayout
title={t('education.page.title', locale)}
description={t('education.page.description', locale)}
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/zh-CN') },
{ name: t('nav.education', locale) }
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy for Education',
url,
offers: educationOffers(locale)
}),
faqPageNode(url, faqs)
]}
>
<HeroSection client:load locale="zh-CN" />
<PricingSection
client:visible
locale="zh-CN"
education
headingLevel="h2"
id="plans"
/>
<HowItWorksSection locale="zh-CN" />
{
stories.length > 0 && (
<CustomerStoriesSection client:visible stories={stories} locale="zh-CN" />
)
}
<FAQSection locale="zh-CN" client:visible />
<CtaSection locale="zh-CN" />
</BaseLayout>

View File

@@ -61,7 +61,13 @@
@theme {
--color-site-dropdown: #332b38;
--color-site-bg-soft: color-mix(
in srgb,
var(--color-primary-comfy-ink) 88%,
black 12%
);
--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;
@@ -77,6 +83,7 @@
--color-transparency-ink-t80: rgb(33 25 39 / 0.8);
--font-formula: 'PP Formula', sans-serif;
--font-formula-narrow: 'PP Formula Narrow', sans-serif;
--text-2xs: 0.6875rem;
--text-3\.5xl: 2rem;
--text-6\.5xl: 4.125rem;
--radius-4xl: 2rem;
@@ -270,6 +277,6 @@ video::-webkit-media-controls-panel {
:root {
--site-bg: var(--color-primary-comfy-ink);
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);
--site-bg-soft: var(--color-site-bg-soft);
--site-border-subtle: rgb(255 255 255 / 0.1);
}

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,22 @@
<script setup lang="ts">
import CustomerStories01 from '../../components/blocks/CustomerStories01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import type { StoryCard } from '../../utils/customers'
// Stories arrive as a prop because the customer content collection is async and
// only loadable in the Astro page (see edu.astro).
const { stories, locale = 'en' } = defineProps<{
stories: StoryCard[]
locale?: Locale
}>()
</script>
<template>
<CustomerStories01
:heading="t('education.customerStories.heading', locale)"
:subtitle="t('education.customerStories.subheading', locale)"
:stories="stories"
:locale="locale"
/>
</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,35 @@
<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'
const mobileBackdropSrc =
'https://media.comfy.org/website/edu/edu_hero-mobile.mp4'
const backdropPoster =
'https://media.comfy.org/website/edu/edu_hero_thumbnail.png'
</script>
<template>
<HeroBackdrop01
:backdrop="{
type: 'video',
src: backdropSrc,
poster: backdropPoster,
alt: t('education.hero.backdropAlt', locale)
}"
:mobile-backdrop="{
type: 'video',
src: mobileBackdropSrc,
poster: backdropPoster,
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,6 +28,21 @@ export function nextStory<T extends { id: string }>(
return ordered[(index + 1) % ordered.length]
}
// Slugs of the Creative Campus stories featured on the /education carousel. Selecting by
// slug (rather than the localized `category` label, which differs per locale) keeps
// the education set stable across locales.
const EDUCATION_STORY_SLUGS = new Set([
'ina-conradi',
'xindi-zhang',
'golan-levin',
'kathy-smith',
'ual-cci'
])
export function isEducationStory(slug: string): boolean {
return EDUCATION_STORY_SLUGS.has(slug)
}
export interface StoryCard {
slug: string
title: string

View File

@@ -5,11 +5,13 @@ import { escapeJsonLd } from './escapeJsonLd'
import type { JsonLdGraph } from './jsonLd'
import {
absoluteUrl,
articleNode,
buildPageGraph,
collectGraphIds,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
faqPageNode,
itemListNode,
jsonLdId,
organizationId,
@@ -57,6 +59,42 @@ describe('itemListNode', () => {
})
})
describe('articleNode', () => {
const pageUrl = 'https://comfy.org/customers/acme/'
it('attributes the article to its page and to Comfy Org', () => {
const node = articleNode({
siteUrl,
pageUrl,
title: 'Acme ships faster',
description: 'How Acme used Comfy',
imageUrl: 'https://media.comfy.org/acme.webp',
locale: 'en'
})
const webPageRef = { '@id': jsonLdId(pageUrl, 'webpage') }
const orgRef = { '@id': organizationId(siteUrl) }
expect(node['@id']).toBe(jsonLdId(pageUrl, 'article'))
expect(node.headline).toBe('Acme ships faster')
expect(node.isPartOf).toEqual(webPageRef)
expect(node.mainEntityOfPage).toEqual(webPageRef)
expect(node.author).toEqual(orgRef)
expect(node.publisher).toEqual(orgRef)
})
it('drops image and description from the markup when not supplied', () => {
const node = articleNode({
siteUrl,
pageUrl,
title: 'Acme',
locale: 'zh-CN'
})
const emitted = JSON.parse(JSON.stringify(node))
expect('image' in emitted).toBe(false)
expect('description' in emitted).toBe(false)
expect(emitted.inLanguage).toBe('zh-CN')
})
})
describe('softwareApplicationNode', () => {
it('claims Comfy Org as author and publisher only when first-party', () => {
const node = softwareApplicationNode({
@@ -164,6 +202,25 @@ describe('productNode', () => {
})
})
describe('faqPageNode', () => {
const pageUrl = 'https://comfy.org/education/'
it('maps each FAQ to a Question with an accepted answer', () => {
const node = faqPageNode(pageUrl, [
{ question: 'What discount do I get?', answer: 'Up to 25% off.' }
])
expect(node['@type']).toBe('FAQPage')
expect(node['@id']).toBe(jsonLdId(pageUrl, 'faq'))
const questions = node.mainEntity as Record<string, unknown>[]
expect(questions).toHaveLength(1)
expect(questions[0]).toMatchObject({
'@type': 'Question',
name: 'What discount do I get?',
acceptedAnswer: { '@type': 'Answer', text: 'Up to 25% off.' }
})
})
})
describe('comfyUiSourceCodeNode', () => {
it('links the source code to the ComfyUI application via targetProduct', () => {
const node = comfyUiSourceCodeNode(siteUrl)

View File

@@ -146,6 +146,33 @@ export function itemListNode(
}
}
interface ArticleInput {
siteUrl: string
pageUrl: string
title: string
description?: string
imageUrl?: string
locale: Locale
}
export function articleNode(input: ArticleInput): JsonLdNode {
const webPageRef = { '@id': jsonLdId(input.pageUrl, 'webpage') }
const orgRef = { '@id': organizationId(input.siteUrl) }
return {
'@type': 'Article',
'@id': jsonLdId(input.pageUrl, 'article'),
headline: input.title,
name: input.title,
description: input.description,
image: input.imageUrl,
inLanguage: input.locale,
isPartOf: webPageRef,
mainEntityOfPage: webPageRef,
author: orgRef,
publisher: orgRef
}
}
interface WebPageInput {
siteUrl: string
locale: Locale
@@ -320,6 +347,26 @@ export function productNode(input: ProductInput): JsonLdNode {
}
}
export interface FaqEntry {
question: string
answer: string
}
export function faqPageNode(pageUrl: string, faqs: FaqEntry[]): JsonLdNode {
return {
'@type': 'FAQPage',
'@id': jsonLdId(pageUrl, 'faq'),
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer
}
}))
}
}
export interface PageGraphInput {
url: string
name: string