Compare commits

..

83 Commits

Author SHA1 Message Date
Maanil Verma
4e7f6a6a6b fix(onboarding): address coderabbit review findings 2026-07-17 16:59:45 +05:30
Maanil Verma
bfaf39d66c Merge branch 'pysssss/product-coachmarks' into feat/onboarding-getting-started
# Conflicts:
#	src/composables/useFeatureFlags.ts
#	src/platform/remoteConfig/types.ts
#	src/platform/telemetry/types.ts
2026-07-17 13:00:03 +05:30
Maanil Verma
e9caa313d4 test(onboarding): fix the coachmark drift-guard's assets-tab selector
getByRole('button', { name: 'Media Assets' }) never matched a real
button: the assets tab button is labelled "Assets" (its tooltip), while
"Media Assets" is only the panel header span (no button role). Open the
tab via the assetsTab page object, as appMode.spec already does.
2026-07-17 12:35:19 +05:30
Maanil Verma
62bb4d4346 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks 2026-07-17 11:41:51 +05:30
AustinMroz
3be998aaf4 Add indicator for free tier quota system (#13657)
Adds frontend support for a quota based free tier.
- Adds a indicator component beneath the run button (docked/undocked/app
mode)
- Tracks available usages as workflows are queued. This is enforced by
backend, but updated quota information is not sent after initial load.
- Tracks partner node presence in graph (Workflows with partner nodes
are not allowed under the quota system)

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/c8dd53c3-9eff-4712-a4c2-c3d31bbbae35"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c9410799-130d-4332-b2f8-20e993402f2c"
/>|

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-07-17 04:25:26 +00:00
Alexander Brown
99674df73b chore: add fallow dev dependency, scripts, and minimal config (#13271)
Adds the [fallow](https://github.com/fallow-rs/fallow)
codebase-intelligence analyzer as a devDependency to surface unused
code, duplication, and circular dependencies.

## Changes
- `package.json` — `fallow` devDep (`catalog:`) + scripts:
  - `pnpm fallow` — full-repo scan
  - `pnpm fallow:audit` — changed-files gate (suitable for CI)
- `pnpm-workspace.yaml` — `fallow` catalog entry
- `.fallowrc.json` — minimal config: entry points (`src/main.ts`,
`index.html`) + duplicate-clone threshold, ignoring generated files
- `.gitignore` — `.fallow/` cache dir

This PR only wires up the tool. The first scan surfaced cleanup
candidates (unused deps/exports, duplicate exports, circular deps);
those are tracked as separate follow-up PRs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:32:29 +00:00
Dante
cfaf89edea feat: role-aware billing status banner for team workspaces (FE-1246) (#13641)
Adds the single billing-status banner slot for team plans, rendered in
priority order — **paused > payment declined > out of credits > ending**
— with role-aware CTAs. At most one state shows at a time.

Placement matches the prototype (`comfydesigner/team-workspaces-v1`) and
Figma: the banner lives in the **workspace settings panel**, in one slot
between the tab list and the tab body, so it shows on every tab. Our
container has two tabs, so one mount covers both rather than duplicating
the banner into each panel as the prototype does.

Derivation lives in a pure `deriveBillingBanner()`; the component only
renders. State comes from `billing-status` fields, never from error
strings (per Luke's user story).


## Gated on the plan, not the workspace type

`useBillingContext` gains `isTeamPlan`: a credit stop marks the
per-credit Team plan, a `team-` slug the retired seat-based ones.
Workspace type is the wrong question in both directions:

- A **team workspace can sit on a retired seat-based plan** (tier
STANDARD/CREATOR/PRO) — the type gate admits it.
- Once consolidated billing lands (cloud#5010), a **personal workspace
can hold a team plan**. The backend blocks that today pending BE-1526,
so the type gate is only accidentally right, and only for now. Per
[Hunter](https://comfy-organization.slack.com/archives/C0BEE5503RQ/p1784151520761519?thread_ts=1784051425.176829):
*"We should deprecate the workspace type and derive it from the
subscription."*

Two deliberate non-choices:

- **Not gated on `isActiveSubscription`**, unlike the existing
`isLegacyTeamPlan`. The spend gate folds `billing_status` into
`is_active`, so a paused or payment-failed team plan reports
`is_active=false` — it must still read as a team plan exactly when the
banner is needed.
- **Not gated on `subscription_tier === 'TEAM'`**, which would silently
drop every legacy team subscriber. The FE cannot express `'TEAM'`
anyway: `tierPricing.ts` resolves `SubscriptionTier` from the
**registry** spec for what is an **ingest** field. That's a real bug,
but orthogonal — filing separately.

## Fixes payment_failed, which was dead code

`payment_failed` denies spend, so it always arrives with
`is_active=false` — and the check sat **below** the `is_active` gate,
two lines under a comment documenting that exact trap for `paused`.
**Every team in Stripe dunning saw no banner.** Now hoisted alongside
`paused`.

Its tests passed only because they spread the `funded` fixture
(`isActiveSubscription: true`) onto `payment_failed` — a pairing the
backend never emits. They now pin `is_active=false`. Both this and the
`isTeamPlan` decoupling are mutation-tested: reverting either kills
tests.

Members no longer fall through from `payment_failed` to out-of-credits —
with the real pairing that path was unreachable, and DES-380 says
members never see the payment banner.

## Renders

The six role/state variants render from the Storybook stories at
`Platform/Workspace/BillingStatusBanner`. Each story drives the real
`deriveBillingBanner` through a stubbed billing context, so a story can
only show a state the backend can actually produce — not a hand-set
banner kind.

Run them with `DISTRIBUTION=cloud pnpm storybook` — the banner is
cloud-only and `isCloud` is compile-time, so a plain `pnpm storybook`
renders every story empty.

Icon language follows the prototype's severity rule: amber
triangle-alert for every action-needed state (paused, payment declined,
out of credits), muted circle-alert reserved for the informational "plan
ends" notice.

## Feature flag

Personal-workspace routing now keys off **`billing_control_enabled`**,
replacing `consolidated_billing_enabled` (which had exactly one consumer
— this routing check — so it is replaced rather than left alongside).
Cloud registers the new flag in
[Comfy-Org/cloud#5091](https://github.com/Comfy-Org/cloud/pull/5091).

This coupling is load-bearing for the banner. `isTeamPlan` requires the
workspace billing rail, so a personal workspace holding a team plan only
surfaces billing state once its routing flag is on. Had routing stayed
on `consolidated_billing_enabled` while the subscribe path unblocks
under `billing_control_enabled`, the two could diverge: a personal
workspace could buy a team plan and then be routed to legacy, which
carries no `billing_status` at all, blanking the banner.

**Merge order:** cloud#5091 must merge and `billing_control_enabled`
must exist in PostHog before this ships, otherwise the flag resolves
false for everyone and every personal workspace routes to legacy. Both
flags default false, so the floor is today's behaviour — but any users
already rolled out on `consolidated_billing_enabled` would revert to
legacy billing until `billing_control_enabled` is rolled out to them.
Worth confirming the current rollout state before merge.

## Backend status

`billing_status: 'paused'` merged in cloud
[#5075](https://github.com/Comfy-Org/cloud/pull/5075) but is **not
emitted yet**: the lifecycle handler that writes it is unreachable in
prod (ingest's forward allowlist omits `customer.subscription.*`;
billing-api is ClusterIP with no ingress). That's BE-1530's unfinished
half, tracked backend-side. The paused banner stays inert until then.

The other three states render from fields the API already returns:
`billing_status=payment_failed` + `renewal_date`, `has_funds`, and
`cancel_at`.

## How has this been tested?

- `useBillingContext.test.ts` — `isTeamPlan` across per-credit, legacy,
paused, payment-failed, and team-workspace-on-personal-plan
- `deriveBillingBanner.test.ts` — priority order, role gating, and the
realistic `is_active=false` pairing for both paused and payment_failed
- `BillingStatusBanner.test.ts` — per-variant copy and actions, date
interpolation + no-date fallback, cross-mount dismiss, ending read-only
for a non-original owner
- `useBillingBanner.test.ts` — dismiss resets after a top-up so a later
exhaustion re-shows
- `WorkspacePanelContent.test.ts` — the banner takes one slot above the
tab body
- 696 tests pass across the touched areas; typecheck / lint / knip clean

## Known gaps

- **Blocked-run surface:** settings-scoped per the prototype, so a user
who exhausts credits mid-canvas won't see it until they open settings.
Queue-time blocked-submit is carried by the existing
insufficient-credits dialog. An app-shell surface would be a design
call, not this PR.
- **Personal workspaces on consolidated billing** get no banner —
`isTeamPlan` excludes them by design, matching FE-1246's team scope and
the `!isInPersonalWorkspace` precedent in `useSubscriptionDialog`. Their
copy would need personal variants (`outOfCredits.body` says "Your
team…") and a free-tier decision. Belongs with the consolidated-billing
rollout.
- **Prototype slot:** the prototype's banner exposes an `actions` slot
used only by its Invoices view. We have no such view, so it's omitted
until needed.


## screenshots

<img width="1150" height="574" alt="01-paused-owner"
src="https://github.com/user-attachments/assets/c1ff0abd-0286-4453-85f2-6f89cff1e655"
/>
<img width="1150" height="574" alt="02-paused-member"
src="https://github.com/user-attachments/assets/ad5ce843-cbc6-49d1-95fc-28e908bc010a"
/>
<img width="1150" height="574" alt="03-payment-declined"
src="https://github.com/user-attachments/assets/717c1191-8f12-4835-90c6-5fe345b20780"
/>
<img width="1150" height="574" alt="04-payment-declined-no-date"
src="https://github.com/user-attachments/assets/c7e483e4-e0cb-4810-b090-a9c432546dbd"
/>
<img width="1150" height="574" alt="05-out-of-credits-owner"
src="https://github.com/user-attachments/assets/9261fd39-ecdb-4501-a401-47d9cbee04dc"
/>
<img width="1150" height="574" alt="06-out-of-credits-member"
src="https://github.com/user-attachments/assets/4a91af88-4405-43e1-8975-b64fdf180a13"
/>
<img width="1150" height="574" alt="07-ending-owner"
src="https://github.com/user-attachments/assets/80e5011e-ef11-482f-bc49-1ddfbe991221"
/>
<img width="1150" height="574" alt="08-ending-non-original-owner"
src="https://github.com/user-attachments/assets/129b79aa-1427-4286-829c-eed501a6d44d"
/>
2026-07-17 00:08:30 +00:00
claude[bot]
a154e6a311 Redirect comfy.org/login to cloud.comfy.org/login (#13727)
<!-- ccr-slack-attribution -->
_Requested by **nav** · [Slack
thread](https://comfy-organization.slack.com/archives/C0B61J7QCJW/p1784217313626029?thread_ts=1784217313.626029&cid=C0B61J7QCJW)_

## Summary

Redirect comfy.org/login to cloud.comfy.org/login.

## Changes

- **What**: Adds a `/login` entry to the `redirects` array in
`apps/website/vercel.json`, matching the existing `/blog` ->
`https://blog.comfy.org` cross-origin redirect pattern.

## Review Focus

- Before: `comfy.org/login` had no redirect (404/no route).
- After: `comfy.org/login` returns a 302 (`permanent: false`) to
`https://cloud.comfy.org/login`.
- How: Astro's native redirects don't support cross-origin destinations,
so this uses Vercel's `redirects` config in `apps/website/vercel.json`,
consistent with the existing `/blog` entry.

## Screenshots (if applicable)

N/A

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UZGZ27NenZf5dzdvNcTFev)_

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 22:20:02 +00:00
steven-comfy
46fec1d47d feat: add hidden /booking-confirmation page for meeting-booking confirmations (#13730)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/booking-confirmation`, a hidden `noindex` page intended as the
post-submit redirect target after a visitor books a meeting with the GTM
team. The page confirms the booking, tells the visitor to check their
email for the calendar invite, and offers a small list of "while you
wait" resources.

## Design

Mirrors the `/individual-submission` template landed in #13697 —
`BaseLayout` + `HeroSection` — so typography and spacing feel native to
the marketing site. The "Resources while you wait" heading uses the
yellow-italic accent used elsewhere on the site; each resource is a
centered `label — url` row.

Resource links source their URLs from `config/routes.ts` where possible
(`routes.learning`, `routes.customers`, `externalLinks.workflows`,
`externalLinks.docs`) so the page stays in sync if those paths ever
move.

## Hidden-page mechanics

Follows the pattern already used for `/individual-submission`,
`/terms-of-service`, and `/payment/*`:

- `noindex` meta tag on the page (both `en` and `zh-CN`)
- Both `/booking-confirmation` and `/zh-CN/booking-confirmation` added
to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts` via
`LOCALE_PREFIXES`, so neither URL is emitted in the sitemap
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`
- English + Simplified Chinese variants both exist as full pages
(matching the existing `/individual-submission` treatment)

## Next step (not in this PR)

Someone with HubSpot / Chili Piper access needs to configure the
meeting-booking flow to redirect visitors to
`https://comfy.org/booking-confirmation` after they schedule.

## Verification

- `pnpm typecheck` → 0 errors, 0 warnings (10 pre-existing hints
unrelated to this change)
- `pnpm build` (in `apps/website`) → 504 pages built;
`/booking-confirmation/` and `/zh-CN/booking-confirmation/` emitted;
neither appears in `dist/sitemap-0.xml`
- `oxlint` and `oxfmt` clean on changed files (also enforced by
lint-staged pre-commit)
- Playwright preview at 1280×900 (English + zh-CN) and 390×844 (English
mobile) — hero, body copy, section header, and all 4 resource rows
render as expected; no horizontal overflow on mobile; only console noise
was transient Vite dep-optimizer 504s unrelated to this change
- `/review` (oracle) flagged one issue on the initial revision: the
zh-CN page was `noindex` but its URL still appeared in the sitemap.
Fixed by extending the exclusion set across both `LOCALE_PREFIXES`; the
follow-up `/review` returned 0 findings.

## Screenshots

## Screenshots

![Desktop 1280x900 view of /booking-confirmation: centered hero 'You're
booked.', body 'Check your email for the calendar invite and meeting
link!', yellow-italic 'Resources while you wait' heading, and four
centered rows showing Learning Center/Workflow templates/Customer
stories/Docs with their comfy.org
URLs.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033519-7d23611a-06c4-4a0b-9a2d-1bd641cc4141.png)

![Mobile 390x844 view of /booking-confirmation: same layout stacks
cleanly with no horizontal overflow; resource rows wrap onto two
centered lines
each.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235033915-819c8536-6bb6-4172-af7a-efd28e6df53e.png)

![Desktop 1280x900 view of /zh-CN/booking-confirmation: Chinese
translation with hero '预约成功。', '等待期间的资源' heading, and four resource rows
in Simplified
Chinese.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/dc1451fed0bffdcc7c874fed8136c9b5f9b791d43773363fd4081bf285d8433e/pr-images/1784235034319-32b66ebb-50ae-47fb-ac64-be27b7e5a278.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Michael B <michael@imick.io>
2026-07-16 22:11:14 +00:00
Alexander Brown
1052658a02 refactor: isolate node locale serialization (#13643)
## Summary

Move node-definition locale serialization into a focused, testable
module while preserving generated locale behavior.

## Changes

- **What**: Extract locale key normalization and Vue I18n escaping from
the Playwright collector into `nodeDefLocaleSerializer.ts`.
- **What**: Keep input and output tooltips raw while escaping fields
compiled by Vue I18n.
- **What**: Replace broad utility tests with behavioral serializer
coverage for escaping, output shapes, ordering, and missing values.

## Review Focus

Check parity with the generator introduced in #13631, especially raw
tooltips, runtime widget labels, known data-type output elision, and
normalized keys.

Validation: `pnpm typecheck`, focused Vitest, oxlint, ESLint, and oxfmt.
2026-07-16 22:08:01 +00:00
Maanil Verma
2a2c4819ad Merge branch 'main' into feat/onboarding-getting-started 2026-07-17 00:39:17 +05:30
Maanil Verma
897e92f434 Merge remote-tracking branch 'origin/main' into feat/onboarding-getting-started 2026-07-17 00:31:31 +05:30
steven-comfy
f524683f3c feat: add hidden /individual-submission page for sales-form redirects (#13697)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `/individual-submission`, a hidden `noindex` page intended as the
post-submit redirect target for the HubSpot sales form when the visitor
selects the "individual" option. The page thanks the visitor, points
them at the self-serve plans on `/cloud/pricing`, and offers a
`mailto:gtm-team@comfy.org` fallback for enterprise inquiries.

## Design

Mirrors the `terms-of-service.astro` template — `BaseLayout` +
`HeroSection` — so typography and spacing feel native to the marketing
site. Plan names (**Standard**, **Creator**, **Pro**, **Teams**) are
highlighted with the yellow-italic accent used elsewhere on the site.

## Hidden-page mechanics

Follows the pattern already used for `/terms-of-service` and
`/payment/*`:

- `noindex` meta tag on the page
- Path added to `SITEMAP_EXCLUDED_PATHNAMES` in `astro.config.ts`
- English-only, with a `/zh-CN/individual-submission` →
`/individual-submission` redirect (matches `termsOfService` handling)
- Not linked from `routes.ts`, `HeaderMain`, or `SiteFooter`

## Next step (not in this PR)

Someone with HubSpot access needs to configure the form (portal
`244637579`, form `94e05eab-1373-47f7-ab5e-d84f9e6aa262`) to redirect to
`https://comfy.org/individual-submission` when the "individual" option
is selected. Happy to add a JS-side fallback in a follow-up PR if
HubSpot's conditional redirects aren't a fit.

## Verification

- `pnpm typecheck` → 0 errors (10 pre-existing hints unrelated to this
change)
- `pnpm build` → 499 pages built; `/individual-submission/` and
`/zh-CN/individual-submission/` (redirect stub) emitted; sitemap
excludes both
- Playwright preview at 1280×900 and 390×844 — hero title, prose,
plan-name highlighting, CTA button, and mailto link all render as
expected; no console errors; `whitespace-nowrap` keeps the email address
on one line on mobile
- `/review` (oracle) on the previous revision: 0 findings; only change
since is the URL rename
- `/zh-CN/individual-submission` redirect verified in preview

## Screenshots

![Desktop view of /individual-submission: centered hero title 'Thanks
for reaching out.', body copy with Standard/Creator/Pro/Teams in yellow
italic, yellow 'See plans and pricing' CTA button, and a smaller mailto
fallback
line.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692177-e7fe7ed9-8844-4625-9823-1dd719173c73.png)

![Mobile 390px view of /individual-submission: same layout stacks
cleanly, gtm-team@comfy.org email stays on one line, no horizontal
overflow.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/4ce277dd60743a347e517f79e6707eef6b43ec5fb8f54a92ea8c52d3858fb258/pr-images/1784158692523-e1af7deb-1379-4280-9bcc-0c602bb12aff.png)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: imick-io <michael@concreo.io>
Co-authored-by: Michael B <michael@imick.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:49:30 +00:00
Maanil Verma
1d2ac9ca27 feat(onboarding): Getting Started screen and tour mounts
- Getting Started screen with curated template cards
- mount the first-run overlay, nudge, and Getting Started screen in GraphView
2026-07-16 23:50:46 +05:30
Maanil Verma
d23a00b5cb feat(onboarding): first-run canvas tour
- residual view store + controller bridging the shared coachmark engine
- overlay: multi-hole scrim, camera choreography, Floating UI placement, shared
  CoachmarkCard, result media and generating states
- post-run nudge with deferral behind the upgrade modal
- first-run telemetry funnel; e2e coverage
2026-07-16 23:49:20 +05:30
Maanil Verma
ea15f1af8a feat(onboarding): resolve tour roles from the loaded workflow
- roleResolver extracts source/prompt/engine/sink roles (subgraph-aware)
- sequenceBuilder turns roles into the ordered step list by shape
- toCoachSteps projects steps onto the shared engine (targetless, 1:1 aligned)
- subgraph navigation restores the root view
- vendored workflow_templates fixtures (marked generated) as resolver ground truth
2026-07-16 23:48:11 +05:30
Maanil Verma
6709d27c71 feat(onboarding): shared contracts for the first-run tour
- onboarding_tour feature flag (remoteConfig + useFeatureFlags)
- fold first-run telemetry events into the shared onboarding schema
- useDesktopLayout composable (shared by the tour gate and app-mode triggers)
- expose UPGRADE_DIALOG_KEYS; thread templateId through useTemplateUrlLoader
- data-testids on the action bar and tab list; onboarding candidate predicate
2026-07-16 23:36:11 +05:30
Maanil Verma
2697a43f78 feat(onboarding): canvas spotlight adapter for the first-run tour
Client-space geometry for canvas-anchored tour targets: node rects, camera
framing, mask rects, and transform-settle tracking.
2026-07-16 23:34:43 +05:30
Maanil Verma
d5fd3d10aa feat(onboarding): generalize the coachmark engine for a second consumer
- EntryPath adds 'firstRun'; TOURS is Partial so an injected tour needs no static entry
- startTour accepts a precomputed step definition; activeTour is exposed read-only
- engine telemetry and TourOverlay cover only registry (TOURS) tours; injected tours self-render and self-report
- TourOverlay.test mock provides activeTour for the new gate (assertions unchanged)
2026-07-16 21:40:28 +05:30
Benjamin Lu
d1d55585f9 [GTM-278] Report Firebase auth state to Desktop telemetry (#13687)
## Summary

Reports each hosted Cloud renderer's declarative Firebase auth state to
Desktop. Desktop remains the sole owner of PostHog identity and
arbitrates all live `WebContents` before changing the process-global
person.

Linear: [GTM-278](https://linear.app/comfyorg/issue/GTM-278) · Parent:
[GTM-93](https://linear.app/comfyorg/issue/GTM-93)

## Changes

- reports `pending` as soon as host auth synchronization starts
- waits for Firebase initialization, then reports `signed_out` or
`signed_in` with the Firebase UID
- reports restored sessions, logout, and direct account switches through
the same declarative state contract
- makes repeated same-UID Firebase updates a no-op
- adds the optional `reportFirebaseAuthState` contract to the Desktop
bridge types
- keeps host bridge failures from interrupting renderer startup or
Firebase auth

## Ownership and privacy

- the frontend never calls PostHog `identify`, `alias`, bind, or unbind
APIs
- browser PostHog provider behavior is unchanged
- the auth-state message is local to Desktop; Desktop #1272 applies its
own telemetry-consent gate before any PostHog identify or emission
- `enable_telemetry` controls only the frontend `HostTelemetrySink`
(renderer event forwarding), so this observer deliberately does not
share that rollout flag
- no canonical `comfy_user_id`, `/api/user` lookup, Cloud API
dependency, Redis state, token redemption, or website transport change
is added

## Review and rollout order

Review [Comfy-Desktop
#1272](https://github.com/Comfy-Org/Comfy-Desktop/pull/1272) first
because it defines the trusted state-reporting API and consensus policy.
Deploy this frontend first: the method is optional and therefore a no-op
on older Desktop builds. Then release Desktop #1272, and only afterward
deploy [comfy-router
#34](https://github.com/Comfy-Org/comfy-router/pull/34).

Auth [Comfy-Desktop
#1222](https://github.com/Comfy-Org/Comfy-Desktop/pull/1222) must not
become an identity authority; with Desktop #1272 its legacy imperative
bind is inert and the post-reload Firebase report is the source of
truth.

## Testing

- `pnpm test:unit src/platform/telemetry/hostUserIdSync.test.ts
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts`
— 53 tests passed
- `pnpm typecheck`
- targeted Oxlint and ESLint
- targeted `oxfmt --check`
- `pnpm knip`
- pre-commit typecheck/lint/format hooks
- pre-push `knip`
- `git diff --check`

No user-facing UI changes.
2026-07-16 04:22:41 +00:00
Benjamin Lu
98c654df20 fix: report page views to Customer.io in-app SDK (#13560)
## Summary

Report the current URL to Customer.io after its in-app plugin
initializes and on every client-side navigation, allowing queued web
in-app messages to satisfy page rules and render.

## Changes

- **What**: Wait for the in-app plugin to register, flush an initial
URL-based `page()` call, and consume the router's existing page-view
dispatches for subsequent SPA navigation.
- **Tests**: Cover initial page reporting, plugin-registration ordering,
and client-side route changes.

## Review Focus

Customer.io intentionally receives `page()` without an explicit page
name. Passing the telemetry page title would make the in-app SDK match
page rules against that title instead of the full URL; the live campaign
requires `contains cloud.comfy.org`.

Root cause: the production SDK debugger showed a correctly identified
user and active SSE connection, but `Route: NONE` and no eligible
messages because the provider did not implement `trackPageView()`.

Complements #13556, which addresses identity stitching for existing
email-keyed Customer.io profiles.

Validation: targeted unit tests (21/21), `pnpm typecheck`, targeted
ESLint and oxfmt checks, and the pre-push `knip` gate.
2026-07-16 02:02:59 +00:00
Benjamin Lu
5bce9c4874 fix: sequence Customer.io identification before auth events (#13677)
We need this fix urgently, blocking all welcome emails and fixing
several vectors.

## Summary

Identify Customer.io users with Firebase UID and email traits, then wait
for that profile update before sending `app:user_auth_completed` so
Welcome Onboarding entrants satisfy the Valid Email Address segment.
Preserve the correct session or anonymous identity after the auth event
is handed to the SDK.

## Changes

- **Auth delivery ordering**: Queue auth events with their Firebase
identity and send the event only after Customer.io `identify(uid, {
email })` settles. Missing-email identities fall back to UID-only
identification, and rejected identifies are logged without suppressing
auth telemetry.
- **Identity lifecycle**: Serialize identity transitions and event
handoff, deduplicate matching `identify()` operations, and snapshot
resolved UID/email together. After auth delivery, restore the configured
or resolved session identity—or reset to anonymous—including for
SDK-late buffered events and auth callbacks that arrive after logout.
Ordinary event delivery does not hold the identity queue open while its
network request completes.
- **Payload privacy**: Use email as a Customer.io person trait while
excluding raw `email` and `share_id` from Customer.io event properties.
- **Dependencies**: None.

## Incident evidence

- Campaign 1 triggers on `app:user_auth_completed` with
`is_new_user=true` and requires the Valid Email Address segment.
- In a recent 15-minute sample, all 30 new-user events had an email
property, while all 30 corresponding Customer.io person profiles had
`identifiers.email=null`.
- Starts fell from 556/day through June 30 to 23.5/day after July 1,
excluding a one-day spike.
- Event properties do not update Customer.io person identifiers, and the
previous fire-and-forget identify allowed the auth event to race the
profile update.

## Review Focus

The auth result's `user_id` is the authoritative Firebase UID for the
identify that gates `app:user_auth_completed`. Once the SDK snapshots
that event, the provider restores the current configured/resolved
identity or the signed-out anonymous state before handing off later
events. Matching identity operations are deduplicated; repeated auth
completions remain distinct telemetry events. A rejected identify must
not suppress auth telemetry.

This supersedes the partial fixes in #13556 (email trait without
ordering) and #12888 (reset/re-login and redaction without the
email-profile race fix).

## Validation

- `pnpm test:unit
src/platform/telemetry/providers/cloud/CustomerIoTelemetryProvider.test.ts`
— 35 passed
- `pnpm typecheck`
- File-scoped type-aware Oxlint
- File-scoped ESLint
- File-scoped oxfmt check
- `pnpm knip` via the pre-push hook

A browser E2E test is not practical for this SDK delivery-ordering fix:
the regressions require controlling unresolved Customer.io
`identify`/`track` promises and observing the active identity when each
event is handed to the SDK. The focused unit suite controls those
boundaries directly and covers SDK-late queueing, configured overrides,
identity restoration, logout races, missing email, rejection fallback,
payload redaction, and non-blocking event delivery.

## Rollout / backfill

- Deploy or backport this change to the active cloud release line.
- Verify new-user Customer.io profiles receive a non-null email
identifier before the auth event is processed, then monitor Campaign 1
starts against the pre-July baseline.
- Backfill or re-trigger already affected new users separately; this
frontend change repairs future auth deliveries and does not replay
historical events.
- No live Customer.io campaign changes are included.
2026-07-16 00:44:39 +00:00
Deep Mehta
6ac9b653bb feat(website): six-card MCP capabilities and nine-item FAQ (stacked) (#13694)
## Summary

Stacked on #13693. Ports the capabilities six-card expansion and the
nine-item FAQ rewrite from #13649 (imick-io) onto the reworked page,
keeping the two-option setup module from the base branch as the single
setup implementation.

## Changes

- **What**:
- Capabilities section grows from three to six cards (Direct any model,
Generate in batches, Ship it as an app) with real media wired;
`FeatureRows01` and `VideoPlayer` gain a `fit: cover | contain` option
and a fixed-ratio media column.
- FAQ rewritten to nine items, reordered so setup facts come first;
`FAQSplit01` autolinks URLs in answers (regex includes coderabbit's
character-class fix from the source PR).
- Quality pass on top of the port: FAQ links get the same branded
`focus-visible` rings as the setup section, "download the assets to
local" copy polished, and two new e2e tests cover the six cards and the
nine-item FAQ with its autolinked server URL.
- Deliberately NOT ported from #13649: its
`SetupSection`/`FeatureGrid01` Step 2 changes and the `cloudMcpServer`
route entry. Those solve the buried-URL problem on the old three-step
layout; the base branch's two-option redesign already covers it via
`externalLinks.mcpEndpoint`.

## Review Focus

- Translation splice: `mcp.tools.*` and `mcp.faq.*` blocks taken
verbatim from #13649's branch (en + zh-CN); base branch's hero/setup
keys untouched.
- All 7 e2e tests pass against the production build; tools and FAQ
sections verified rendering with live media.

Linear:
[PM-132](https://linear.app/comfyorg/issue/PM-132/comfyorgmcp-faq-is-stale-and-contradicts-the-page-rewrite-faq-fix)
Source: #13649

## Screenshots (if applicable)

---------

Co-authored-by: Michael B <michael@imick.io>
2026-07-16 00:18:19 +00:00
Mobeen Abdullah
86bccf8d4c feat(website): add brand portal page at /brand (#13484)
## Summary

Add the Brand Portal page at `/brand` (and `/zh-CN/brand`), implementing
the Figma design in English and Simplified Chinese.

## Changes

- **What**: New marketing page with hero, logo system, color palette,
voice guidelines, trademark, and questions sections. Reuses
`BaseLayout`, `Button`, and `SectionHeader`; adds `data/brandColors.ts`
(palette with hex/rgb/hsl/cmyk) and `templates/brand/` sections. The
hero "View brand guidelines" downloads the guidelines PDF; both
"Download logos" actions download the brand-assets zip. Route registered
in `config/routes.ts`; copy in `i18n/translations.ts` (en + zh-CN).
- **Dependencies**: none

## Review Focus

- Typography, spacing, and palette values were matched to the Figma per
section; the Chinese is a first-pass translation.
- The guidelines PDF and logo zip are served from `media.comfy.org` with
`Content-Disposition: attachment` so the buttons download rather than
open inline.
- Contact links point to `/contact` in the same tab (the Figma prototype
annotation used a new tab).

Implements FE-1217.

## Screenshots

**Desktop**

<img width="1440" height="3648" alt="brand-portal-desktop"
src="https://github.com/user-attachments/assets/050855b2-dd2a-4616-b5c8-c2c79b049df8"
/>


**Mobile**

<img width="390" height="6342" alt="brand-portal-mobile"
src="https://github.com/user-attachments/assets/85f3ca97-655e-4fcc-987d-976faeae2efa"
/>


## Test plan

- [x] `pnpm --filter @comfyorg/website typecheck`
- [x] `pnpm lint`
- [x] `pnpm format`
- [x] `e2e/brand.spec.ts` (sections render; logo bundle and
guidelines-PDF download links)
- [x] Verified responsive at 1440 / 834 / 390
2026-07-16 00:07:59 +00:00
Mobeen Abdullah
b52f6ce764 feat(website): strengthen the ComfyUI entity links in JSON-LD (#13689)
## Summary

Implements the genuine parts of the schema enrichment follow-up to
#13480 (FE-1232): the ComfyUI `SoftwareApplication` now names its
canonical page and links back to its `SoftwareSourceCode`. Several other
items from the recommendations doc are deliberately **not** implemented,
with reasoning below.

## Changes

- **`mainEntityOfPage` on the ComfyUI application.** The WebPage already
pointed at the app via `mainEntity`; the app now points back at the home
page, completing the relationship.
- **`isBasedOn` linking the app to its source code.**
`SoftwareSourceCode` already declared `targetProduct` -> the app, but
only on the home page, and the app had no reverse link. The app now
references the source node, so the application and its GitHub repository
resolve as one connected entity.
- **Download pages now emit the source code node** (en + zh-CN). They
render the application node, so without this the new `isBasedOn`
reference would not resolve inside their graph. `/download` previously
described the app with no link to the repo at all.
- Extracted a shared `comfyUiSourceCodeId()` so the `#sourcecode` anchor
has one definition, matching the `comfyUiSoftwareId()` pattern from
#13480 review.
- 3 unit tests: canonical page is set, `isBasedOn` round-trips to the
source node's real `@id`, and third-party software gets neither.

## Review Focus

**Why `mainEntityOfPage` is a plain URL, not an `@id` reference.** The
app node is shared by `/` and `/download` under one site-wide `@id`
(`/#software`). A per-page reference would make the same entity claim
two different canonical pages, and pointing at the home page's
`#webpage` id would leave an unresolved reference on `/download`, which
`validate:jsonld` fails the build on. A plain URL keeps the node
byte-identical everywhere it is emitted.

**Coupling worth knowing about:** any page emitting
`comfyUiApplicationNode` must also emit `comfyUiSourceCodeNode`, or the
`isBasedOn` reference dangles. This is enforced at build time by
`validate:jsonld`, so it fails loudly rather than shipping a broken
graph.

**Third-party software is untouched.** Packs and models get neither
property, the same isolation already applied to `author`, `publisher`,
`seller` and `sameAs`. They are not our application and do not share our
repository.

## Not implemented, and why

The recommendations doc proposed six schema changes. Four are not
actionable:

1. **`Review` + `AggregateRating` (4.8 / 126, "Verified User")** -
invented numbers with no real review system behind them. Google
disallows self-serving ratings that are not backed by genuine on-page
reviews, and this repo's own build validator fails on fabricated
ratings.
2. **`brand` on the `SoftwareApplication`** - not valid. Per schema.org,
`brand` applies to Organization, Person, Product and Service.
`SoftwareApplication` is a `CreativeWork`, not a `Product`, so `brand`
is outside its domain. The doc's own proposed code puts `brand` on a
`Product` node, not on a `SoftwareApplication`, so the recommendation
does not match the example it ships with. The relationship it is
reaching for already exists here: the app declares `author` and
`publisher` -> Comfy Org, the free Offer declares `seller` -> Comfy Org,
and our actual `Product` (Comfy Cloud, on `/cloud/pricing`) already
carries `brand` -> Comfy Org.
3. **`seller` on the Offer** - already shipped in #13480. Live on the
free Offer and on all three pricing tiers.
4. **Strengthening the Organization (logo, `sameAs`)** - already
shipped, and the proposed version is a downgrade: it uses `favicon.ico`
in place of our 512x512 PNG, and its `sameAs` points at the Wikipedia
article for "Comfort" (the feeling). The accompanying `about`/`mentions`
entities map our title words to unrelated pages (Canvas -> Canvas
Networks, Scratch -> Abrasion). We already link the correct Wikidata
entities, the ComfyUI Wikipedia article and G2.

The document's larger "Watch Page" checklist targets a video
watch/listing page that does not exist on this site, and is mostly
content work rather than schema.

## Scope note

This is entity-graph hygiene, not a ranking change. `mainEntityOfPage`
and `SoftwareSourceCode` do not produce rich results. The value is that
machines can resolve that Comfy Org publishes ComfyUI, an open-source
application whose code lives on GitHub.

## Verification

`test:unit` 199 passed, `astro check` 0 errors, `knip` clean, `build`
498 pages, **`validate:jsonld` passed across 501 pages** (confirms no
unresolved `@id` on any page), and e2e for the download, cloud-nodes and
affiliates specs (the JSON-LD asserting ones) 38 passed.

No visual change: this is `<head>` structured data only, so there is
nothing to screenshot.

Ref: FE-1232
2026-07-16 00:07:49 +00:00
jaeone94
5da3e16f33 fix: preserve single-select values on reselection (#13608)
## Summary

Prevent single-select dropdowns from clearing their value when the
currently selected item is selected again. This fixes Load Image and
Load Video previews switching to a missing-media error after media
reselection.

Linear: FE-1239

## Changes

- **What**: Keep the current single-select value, close the dropdown,
and restore trigger focus when the selected item is chosen again.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The behavior is implemented in the shared `FormDropdown` selection
handler, so it applies consistently to all single-select dropdown
consumers rather than special-casing media widgets. Multi-select
toggling remains unchanged.

## Red-Green Verification

- `383576446` adds the unit and Playwright regressions without the
production fix. CI Unit run `29196142841` failed on the new
empty-selection assertion, proving red.
- `d4ae9eb2c` adds the production fix after the red result was
confirmed. CI Unit run `29196531028` passed the same Vitest suite green.

## Test Plan

- Unit regression verifies no selection update is emitted and the
dropdown closes with focus restored.
- Playwright regression reselects `example.png` in Load Image and
verifies the menu closes, the value remains selected, and no load error
appears.
- Targeted Playwright regression passed 10 consecutive local runs with
the production fix applied locally.

## Screenshots (if applicable)

Before 


https://github.com/user-attachments/assets/c3724e16-04fc-4b88-bd92-87004db71596

After 


https://github.com/user-attachments/assets/c99924f1-e446-4eca-aef7-cb8df961ab22
2026-07-15 22:52:11 +00:00
ShihChi Huang
6d0bbd7d7c perf: shard Chromium E2E across 16 jobs (#13650)
## Summary

> [!NOTE]
> Bumping to [24
shards](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13664)
reduces time from 11m14s to 9m31s, but costs 15% more in GitHub Action
runner time. Keep it 16 for now to be conservative and bump to 24 if it
works well for a week

Split Chromium E2E into 16 shards with two Playwright workers each,
preserving the current per-runner worker density while doubling
effective concurrency.

### 16 shards
| Run | E2E time | Shards |
|---|---:|---:|
|
[1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29370163062)
| 11m38s | 16/16 pass |
|
[2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29371313957)
| 11m32s | 16/16 pass |
|
[3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29372283949)
| 10m50s | 16/16 pass |
|
[4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29373163198)
| 11m00s | 16/16 pass |
|
[5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29374055787)
| 11m12s | 16/16 pass |

### 24 shards
| Run | E2E time | Shards |
|---|---:|---:|
|
[1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29379798083)
| 9m13s | 24/24 pass |
|
[2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380286857)
| 9m33s | 24/24 pass |
|
[3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380761689)
| 9m38s | 24/24 pass |
|
[4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381219308)
| 10m02s | 24/24 pass |
|
[5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381669526)
| 9m07s | 24/24 pass |

## Changes

- **What**: Run Chromium as 16 shards with explicit `--workers=2`.

## Review Focus

- Compare full E2E time, shard skew, runner queue time, and flakiness
with the 8×2 baseline.

## Validation

- YAML parse and oxfmt check
- Pre-commit formatting, linting, and root typecheck
- Pre-push Knip
- Local CodeRabbit review is rate-limited for 24 minutes; server-side
CodeRabbit remains pending.

Created by Codex


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Changes are limited to CI workflow and Playwright config; no
application runtime, auth, or data paths are touched.
> 
> **Overview**
> **Chromium E2E CI** now runs as **16 parallel shards** (up from 8),
with **`workers: 2` on CI** in `playwright.config.ts` so each runner
keeps two Playwright workers while overall concurrency doubles.
> 
> Reporter wiring shifts so **blob output is chosen in config** when
`PLAYWRIGHT_BLOB_OUTPUT_DIR` is set (default reporter is `html`
otherwise); workflow steps drop inline `--reporter=blob` from the
Playwright CLI for both sharded Chromium and the other browser matrix
jobs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3707e70611. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-15 20:08:33 +00:00
imick-io
4341972be3 fix(website): remove opacity-80 washing out footer logo colors (#13648)
## Problem
The animated logo in the site footer (a webp frame sequence drawn to a
`<canvas>`) renders with dull, darkened colors. The brand neon yellow
`rgb(242, 255, 90)` shows up as a muddy olive `≈ rgb(198, 209, 80)`, and
the gray/pastel faces are darkened and purple-tinted.

## Cause
The footer `<canvas>` carried an `opacity-80` Tailwind class. At 80%
opacity the browser composites the animation 20% over the dark purple
footer background (`rgb(33, 25, 39)`), shifting every color. The source
webp frames themselves are correct — the shift only happens at display
time.

## Fix
Remove `opacity-80` from the canvas in `SiteFooter.vue` so it renders
the authored frame colors at full opacity.

## Verify
- Pre-check (no deploy): in DevTools, select the footer `<canvas>` and
untick `opacity: 0.8` — colors pop back immediately.
- After change: sample a yellow cube face in the footer animation with a
color picker — it should read `#F2FF5A` / `rgb(242, 255, 90)`, not
`rgb(198, 209, 80)`.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:29:55 +00:00
CodeJuggernaut
98700cfcc7 feat: marquee select and Ctrl/Cmd+A in the Media Assets panel (#13323)
## Summary

Adds marquee (rubber-band) multi-select and Ctrl/Cmd+A select-all to the
Media Assets panel, clips the canvas drag-selection rectangle to the
canvas panel, and turns on live (real-time) node-graph rubber-band
selection by default.

## Changes

- **Marquee select** — rubber-band drag from empty grid space selects
the covered cards; hold Ctrl/Cmd to start a marquee from over a card;
Ctrl/Cmd or Shift alone makes the marquee additive to the current
selection, Ctrl/Cmd+Shift subtracts the covered cards from it
(designer-approved), and no modifier replaces it. Cards update their
selected state live during the drag.
- **Ctrl/Cmd+A** — selects all loaded assets when the pointer is over
the panel, otherwise falls through to the canvas (select all nodes). It
`stopImmediatePropagation`s so a panel select-all never also fires the
global node select-all, and it yields while an `aria-modal` dialog is
open or a text input is focused.
- **Select-all recovers after "deselect all"** — the shortcut was gated
only on `useElementHover`, which latched stale when the floating
selection bar under the cursor unmounted on deselect. It now also checks
the live pointer position against the panel rect, so a second Ctrl/Cmd+A
right after deselecting no longer falls through to the browser's native
page select-all.
- **Canvas rectangle clip** — the canvas drag-selection rectangle is
clamped to the canvas panel bounds (`SelectionRectangle.vue`,
display-only).
- **Graph live selection on by default** — flips the existing
`Comfy.Graph.LiveSelection` setting's default to on, so node-graph
rubber-band selection updates in real time during the drag (matching the
assets panel) instead of committing only on mouse-up. The behavior was
already implemented behind the setting; this changes only the default,
and users with an explicit value keep it.
- **Robustness/UX** — the pointer is captured on drag-engage rather than
on press (so a Ctrl/Cmd-click on a card isn't hijacked); no global
`document.body.userSelect` mutation (replaced by a panel-scoped
`selectstart` guard); the marquee overlay uses the semantic
`primary-background` token; post-drag click-suppression auto-resets so a
cancelled drag can't swallow a later click; `setPointerCapture` is
wrapped in try/catch; a Ctrl/Cmd-held card `dragstart` is cancelled so
no native ghost-drag image appears.
- **Breaking**: none — `useAssetSelection` is extended additively (new
`setSelectedIds` helper, nothing removed or altered) and the new
composable exposes only `{ marqueeStyle }`.
- **Dependencies**: none.

## Review Focus

- **`SelectionRectangle.vue`** is shared canvas code; the change is
display-only (clamps the rectangle to the panel; no node-selection
behavior change).
- **`coreSettings.ts`** — a one-line `Comfy.Graph.LiveSelection` default
flip is the only change that affects graph behavior; the live-select
code path itself is pre-existing.
- **`useAssetGridSelection.ts`** — listener lifecycle/teardown, the
panel-scoped `selectstart` guard, the click-suppression timer, the
capture-on-drag-engage logic, the pointer-position select-all fallback,
and the subtractive-mode snapshot at pointerdown.
- **Ctrl/Cmd+A routing** — panel hover (or a live pointer inside the
panel) gates select-all vs. the canvas, and `stopImmediatePropagation`
prevents double-handling.
- Pure geometry/selection logic is extracted into
`marqueeSelectionUtil.ts` and unit-tested in isolation (`RectEdges` is
`Pick<DOMRect, ...>`, the DOM edge subset); `MediaAssetCard.dragStart`
keeps `main`'s `display_name` payload.

Relates to Linear **FE-910**.

## Testing

- **Unit:** `useAssetGridSelection` (39 cases — marquee selection,
additive/replace, subtractive Ctrl/Cmd+Shift (incl. the macOS Cmd
variant and a shrink-restore drag), interactive-element + list-view
guards, `selectstart` scoping, click-suppression auto-reset,
pointer-capture-throw and capture-on-drag-not-press, modal-aware
Ctrl/Cmd+A, non-propagation, and the deselect-recovery pointer-in-panel
path), plus `MediaAssetCard`, `marqueeSelectionUtil` (11 cases incl.
subtractive, and a 5-case fast-check property suite pinning the
additive/subtractive set invariants), `SelectionRectangle`,
`useAssetSelection`, and `mathUtil`.
- **E2E (`assetsSidebarTab.spec.ts`):** 10 Playwright scenarios running
in CI — Ctrl/Cmd+A hover vs. canvas; a marquee from the panel header; a
modifier-held additive marquee; a Ctrl/Cmd+Shift subtractive marquee;
Ctrl/Cmd-drag from a card and within a single card; Ctrl/Cmd+A ignored
in a focused search box and under an aria-modal dialog; and a drag from
the search box not marquee-selecting. The empty-space marquee path is
covered by the panel-header scenario plus the unit suite (a dedicated
empty-space e2e could not run headless without a local backend and was
dropped as redundant).

## Future work

- **Escape key** — not handled by the marquee/select-all flow yet (the
composable handles only Ctrl/Cmd+A). Follow-up: press Escape to cancel
an in-progress marquee drag (abort the rubber-band and restore the
pre-drag selection) and to clear the current selection while the panel
has focus.
- **Ctrl+A across pagination** — select-all covers the loaded assets
only (confirmed as the intended behavior with design); a
load-all-then-select variant can follow if needed.

## Demo


https://github.com/user-attachments/assets/3841bf3c-db75-4229-a5e7-fb363b4882d6
2026-07-15 09:20:31 +00:00
Comfy Org PR Bot
5bf41a41bd 1.48.3 (#13606)
Patch version increment to 1.48.3

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-15 02:38:12 +00:00
CodeJuggernaut
a7f14a0b3f fix(subscription): size pricing dialogs with Reka props (#13633)
## Summary

Fixes the legacy personal and legacy workspace pricing dialogs so Reka
owns the dialog width and the pricing table no longer overflows the
default 576px frame.

## Changes

- **What**: Replace the shared PrimeVue-only `style` and `pt` dialog
props with Reka `renderer`, `size`, and `contentClass` props for both
legacy pricing paths.
- **What**: Preserve `modal: false` for the legacy workspace path so its
teleported PrimeVue plan-details popover remains interactive.
- **What**: Add unit coverage for both routes and assert that ignored
PrimeVue shell props are no longer passed.

## Review Focus

- **Regression origin**:
[#12593](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12593) made
Reka the default dialog renderer.
[#12666](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12666) then
added shared PrimeVue `style` and `pt` props for these pricing dialogs.
Reka ignored those props and fell back to `size="md"` (`max-w-xl`,
576px).
[#13092](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13092) fixed
the unified pricing path only and explicitly left the two legacy paths
for follow-up.
- **Sizing ownership**: The fix puts width on the Reka dialog frame with
`size: 'full'` and `sm:max-w-7xl`. Pricing content no longer has to
compensate for a narrow shell.
- **Legacy workspace behavior**: `modal: false` remains intentional
because the legacy table opens a PrimeVue popover teleported to `body`.
- **Scope**: This PR contains only global subscription-dialog sizing.
Agent side-panel behavior remains in
[#13472](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13472).
- **Validation**: Focused tests pass (41 tests), `pnpm typecheck`
passes, and targeted ESLint passes. Chrome validation at 1352x705
rendered a 1280px dialog with no horizontal overflow in both the
isolated PR preview and the combined agent-panel preview. The docked
agent panel remained mounted behind the modal.

## Screenshots (if applicable)

- Original report and screenshots: [Slack
thread](https://comfy-organization.slack.com/archives/C0A8Z4U7Y1K/p1783967281397449?thread_ts=1783966866.227439&cid=C0A8Z4U7Y1K)
2026-07-15 00:35:09 +00:00
Christian Byrne
e6d1a9d4a2 fix(release): support manual target-branch override + major versions in resolve-comfyui-release (#13660)
## Problem

`scripts/cicd/resolve-comfyui-release.ts` derived the release target
purely from ComfyUI's `requirements.txt` pin and **hardcoded major
version `1`** (`core/1.${minor}`, `v1.${minor}.*`,
`1.${minor}.${patch}`) even though it parsed `major` and never used it.
Consequences:

- Could not release an out-of-cadence branch (e.g. skip a dead 1.46 to
ship 1.47 directly).
- Could not do a major bump (2.x).

## Changes

1. **Resolver (`resolve-comfyui-release.ts`)** — uses the parsed
`targetMajor` (defaults to the current pin's major) for every
branch/tag/version string instead of literal `1`. `getLatestPatchTag`
now takes a `major` param and globs `v${major}.${minor}.*`.

2. **`TARGET_BRANCH` env override (highest precedence)** — when set,
validates `^core/(\d+)\.(\d+)$`, verifies `origin/<branch>` exists, and
skips the `RELEASE_TYPE`/pin-derived selection entirely. If both
`TARGET_BRANCH` and `RELEASE_TYPE` are set, the override wins.
`current_version` still comes from the pin (for `diff_url` and the
ComfyUI PR "from" version), so the requirements bump jumps straight from
the pin to `target_version` (e.g. 1.45.20 → 1.47.8, skipping 1.46).

3. **Workflow (`release-biweekly-comfyui.yaml`)** — new optional
`target_branch` `workflow_dispatch` input, wired into the resolve step's
`env` as `TARGET_BRANCH`, and surfaced in the run summary. Downstream
jobs consume `target_branch`/`target_version` outputs unchanged.

The output JSON shape is identical. Extracted pure helpers
(`parseTargetBranchOverride`, `computeTargetVersion`) and guarded the
main block so the module is importable by tests.

`release-version-bump.yaml` and `release-branch-create.yaml` were
**already** major-aware and are left untouched.

## Tests

New `scripts/cicd/resolve-comfyui-release.test.ts` (15 cases) covering
`parseRequirementsVersion` (==, >=, missing/absent), `isValidSemver`,
`parseTargetBranchOverride` (valid `core/1.47` and `core/2.0`, malformed
rejected), and `computeTargetVersion` including a non-1 major (`v2.0.3`
+ commits → `2.0.4`).

## Usage

```
gh workflow run release-biweekly-comfyui.yaml --field target_branch=core/1.47
```

releases 1.47.8 directly (skipping a dead 1.46), or `--field
target_branch=core/2.0` for a major bump.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:41:35 +00:00
Dante
0b4a960467 feat(billing): derive next-invoice amount, date, and cadence from billing state (FE-1245) (#13599)
## Summary

Adds the data seam for the Settings > Plan & Credits Invoices tab
(FE-1245): derives the next-invoice amount, date, and billing cadence
from billing state already fetched by the billing context, replacing the
prototype's hardcoded mock.

Updated for the 2026-07-13 design decisions (Slack thread + Willie's
Figma updates): the banner surfaces the BE-provided next-invoice date,
and annual subscriptions now show their yearly total and real renewal
date instead of hiding the banner.

## Changes

- **What**: `useNextInvoice` composable + pure `deriveNextInvoice` in
`src/composables/billing/` — returns `{ nextInvoice:
ComputedRef<NextInvoice | null> }`, `NextInvoice = { amountCents,
renewalDate, duration }`
- Monthly: subscribed team credit stop `monthly.price_cents` (status
`team_credit_stop.id` matched against the ladder) with plan
`price_cents` fallback — unchanged precedence
- Annual: stop `yearly.price_cents * 12` (stop yearly prices are
per-month figures, per `useWorkspacePlanPricing`) or the ANNUAL plan's
`price_cents` as-is (already the yearly total, per
`UnifiedPricingTable`)
- `renewalDate`: BE-computed `renewal_date` passed through untouched —
backends own period math including month-end bias. It goes null the
moment a cancellation is scheduled (mutually exclusive with
`end_date`/`cancel_at` on both billing backends)
- null (banner hidden) when inactive, cancelled, the amount is
unresolvable/non-positive (covers legacy billing's empty plan list and
free tier), or the plan resolved by slug disagrees with the
subscription's cadence
- 13 unit tests cover all branches, including x12-regression fixtures
with discriminated list/discount prices

Intentionally excludes usage/overage pending charges until the backend
exposes an authoritative upcoming-invoice amount (see FE-1245).

Follow-ups (not this PR):

- Cancelled-state toast (Figma 4617:29298 month-remaining / 4617:29992
terminal): separate seam UI work. Data is already available —
`subscription.endDate` is populated by both billing backends exactly
during the cancelled-but-paid window, and `useResubscribe()` covers the
Renew plan action
- "/year" amount framing is shown with placeholder copy; no annual
variant exists in the Figma file yet (design follow-up)

## Review Focus

- Annual unit semantics: stop `yearly.price_cents` is a per-month figure
(`useWorkspacePlanPricing.ts` `teamMonthlyCostCents`), while
`Plan.price_cents` for ANNUAL plans is the yearly total
(`UnifiedPricingTable.vue` `getAnnualTotal`) — hence x12 in one branch
and as-is in the other
- Consumed by the `useWorkspaceInvoices` seam once #13591 lands; the
swap now also requires the seam template to render
`renewalDate`/`duration`, so it is no longer a one-line body change
- FE-1245 / DES-497

## Screenshots

Live captures through the real `WorkspaceInvoicesContent` + this
composable with `/api/billing/*` mocked per state (isolated preview
harness). Date format follows the existing billing convention; "/ year"
framing is placeholder pending the annual design variant.

Active monthly subscription, team credit stop `team_320` — $320 USD with
next-invoice date:

![Invoices — team credit stop $320 with
date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-team-stop.png)

Active monthly subscription without a credit stop — plan-price fallback,
$20 USD:

![Invoices — plan price fallback $20 with
date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-plan-price.png)

Annual subscription — banner now shown: yearly total from the per-month
stop figure (288 x 12 = $3,456) and the real yearly renewal date:

![Invoices — annual
shown](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-annual.png)

Paused subscription — unchanged: next-invoice banner hidden, the paused
banner hosts the Full invoice history action (capture from the earlier
full-app harness):

![Invoices —
paused](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-paused.png)
2026-07-14 23:21:11 +00:00
Christian Byrne
060957d66c fix: use ComfyUI version for Cloud release notes (#13632)
## Summary

On Cloud, `releaseStore.currentVersion` sourced `cloud_version` (e.g.
`0.160.1`) while the `/releases` feed keys its entries by **ComfyUI**
version (e.g. `0.27.1`). The what's-new popup compares the latest feed
entry against the running version, so `0.27.1 < 0.160.1` read as
"already ahead of the latest release" and the popup never showed.

- Analytics confirmed the regression: `release_note` clicks fell from
**13.4% (90d) → 0% (30d)**; `cloud_release_note` was effectively never
clicked.

## Change

- `currentVersion` always uses `comfyui_version` (drops the `isCloud →
cloud_version` branch). The feed request keeps `project: 'cloud'`.
- Rationale: the changelog page and each note's "learn more" link are
ComfyUI-versioned, and Cloud has no separate versioned feed or changelog
page. The changelog is maintained with ComfyUI versions, updated after
each Cloud deploy lands.

## Tests

- Adds a regression test (`isCloud environment (FE-1237)`) pinning that
Cloud uses `comfyui_version`, not `cloud_version`. Verified via negative
control: reverting the fix fails it with `0.160.1` vs expected `0.27.1`.
- `test:unit` (releaseStore): 49 passed · `typecheck`, `lint`,
`format:check`, `knip`: clean.

## ADR

Adds `docs/adr/0012-cloud-release-notes-use-comfyui-version.md`
(Accepted) recording the rationale and history, and updates the ADR
index.

Fixes FE-1237

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:03:58 +00:00
Christian Byrne
1a98362984 fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631)
## Summary

Defense-in-depth fix for the `Invalid linked format` crash: escape
vue-i18n message-syntax characters at node-def generation so the locale
bundle is always valid, **and** harden the runtime so any compile error
(custom nodes, already-shipped bundles) degrades to the raw message
instead of crashing the app.

This folds in the runtime guard from #13603 (@xmarre) — see credit below
— so the two layers land together.

## Changes

**Layer 1 — generation-time escaping (source data)**
- Add `escapeVueI18nMessageSyntax()` to
`packages/shared-frontend-utils/src/formatUtil.ts`. It escapes every
character vue-i18n's message compiler treats as syntax in text — `@ { }
| %` (verified against `@intlify/message-compiler`'s `readText`
tokenizer): `@` and unbalanced `{`/`}` throw, `|` mis-renders as plural
branches, `%` matters immediately before `{`. Each becomes a literal
interpolation `{'x'}` (the only escape vue-i18n supports); the set isn't
exported by the library so it's hardcoded with source/doc references.
Single-pass, applied once.
- Apply it in `scripts/collect-i18n-node-defs.ts` to the fields
**compiled** via `t()`/`st()`: `display_name`, `description`,
input/output `name`, widget labels, `dataTypes`, `nodeCategories`.
Tooltip fields go through `tm()`/`stRaw()` (no compile step) and are
intentionally left unescaped.
- Regression test (`src/locales/escapeNodeDefI18n.test.ts`) compiles the
escaped output for all five characters through real vue-i18n and asserts
it round-trips verbatim.

**Layer 2 — runtime fallback (consumer), folded in from #13603 by
@xmarre**
- `st()` now wraps `t(key)` in try/catch: a vue-i18n `SyntaxError` falls
back to the raw locale message (`tm()`) instead of crashing bootstrap.
Shared `rawTranslationOrFallback` helper reused by `stRaw()`. Tests in
`src/i18n.safeTranslation.test.ts` (isolated per-test, and covering the
non-`SyntaxError` rethrow branch).

**Tooling**
- Lint the collection scripts instead of ignoring them: import the
shared helper via the `@/utils/formatUtil` tsconfig alias (fixes
`import-x/no-relative-packages`; verified it resolves under Playwright)
and drop a stray progress `console.log`. No lint config changes and no
rules relaxed — the scripts are now genuinely linted (they were
previously excluded).

- **Breaking**: none.

## Review Focus

- **Root cause**: values read via `t()`/`st()` are compiled by vue-i18n,
which parses `@ { } | %` as message syntax; a malformed `@` throws
`Invalid linked format`. `app.ts` translates every node description at
startup, so one bad string aborts router load. Surfaced by the 1.47.7
locale sync (#13246), whose `ByteDanceSeedAudio` description contains
`@Audio1-3`.
- **The two layers compose**: escaped output compiles cleanly, so the
runtime catch never fires for generated strings; the catch is the safety
net for strings that bypass generation (custom/third-party nodes via
`mergeCustomNodesI18n`, and locale bundles already shipped in
1.47.x/1.48.x).
- **Compiled vs. raw boundary**: escaping is applied only to
compiler-consumed fields; leaving tooltips raw matches the
`stRaw()`/`tm()` work in #12469.
- **Translation propagation**: lobe-i18n's config prompt already keeps
`{...}` placeholders intact, so the escaped English propagates to all 13
locales.
- **Follow-up (out of scope)**: `scripts/collect-i18n-general.ts`
(commands/settings/menus) shares the same generation-time hazard and
could reuse the same helper later; Layer 2 already covers it at runtime.

## Credit

The runtime fallback (Layer 2) is @xmarre's work from #13603, folded in
here with authorship preserved on the original commits. Closing #13603
in favor of this combined PR.

Fixes #13545
Fixes #13575

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com>
2026-07-14 18:14:47 +00:00
Terry Jia
ab33746b3e fix(load3d): letterbox-aware pointer NDC for the transform gizmo (#13550)
## Summary
The viewport letterboxes the scene to the width/height target aspect,
but TransformControls maps pointers over the full canvas rect, so its
handle raycasts skew toward the center, increasingly wrong toward the
edges whenever the canvas aspect differs from the target aspect.

Adds clientPointToLetterboxNdc() (pure, unit-tested) + a
Viewport3d.clientPointToNdc() wrapper, and routes TransformControls'
pointer math through it via GizmoManager.setPointerNdcSource(). 
Points on the letterbox bars resolve to nothing instead of phantom
handles.

## Screenshots (if applicable)
before
<img width="813" height="703" alt="image"
src="https://github.com/user-attachments/assets/cf5714c3-6b60-4ced-84ce-039f49c7e999"
/>
<img width="673" height="696" alt="image"
src="https://github.com/user-attachments/assets/31f898d2-a820-4748-b960-4f3cc5752095"
/>

after
<img width="1391" height="981" alt="image"
src="https://github.com/user-attachments/assets/18890d89-ff50-4298-bac5-472e00c79e24"
/>
<img width="955" height="712" alt="image"
src="https://github.com/user-attachments/assets/255f3c7d-dbb5-40a8-8865-c023caa1384e"
/>
2026-07-14 13:39:03 -04:00
Benjamin Lu
55c4e807a1 fix: use system hour cycle for queue times (#12297)
## Summary

Use the selected app locale for queue timestamp language and punctuation
while honoring the browser/system 12-hour or 24-hour clock preference.

## Changes

- **What**: `formatClockTime` resolves the hour cycle from the
browser/runtime default locale, then applies it while formatting with
the app locale.
- **What**: Added behavioral unit coverage for the production
two-argument path and explicit 12-hour and 24-hour preference locales.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Queue timestamps already use the browser's local timezone. This PR fixes
only the remaining clock-preference mismatch: app language stays in
control of the rendered time string, while the browser/system locale
controls 12-hour versus 24-hour display.

No Playwright regression test was added because browser/system
hour-cycle preference is environment-level Intl state, not deterministic
UI state that the E2E suite can set reliably. Unit tests pin both hour
cycles through BCP-47 locale preferences and assert the rendered
behavior directly.

Fixes
[FE-252](https://linear.app/comfyorg/issue/FE-252/bug-queue-progress-times-ignore-browsersystem-1224-hour-preference)

## Screenshots (if applicable)

Not applicable.

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-14 16:58:25 +00:00
jaeone94
74147d7ee2 feat: lift boundary-exposed validation errors to the subgraph host (#13542)
## Summary

Backend validation errors (`node_errors`) are reported against the
**flattened** prompt, so an error whose real fix lives on a subgraph
host node gets attached to an interior node the user may never open —
and, after ADR 0009, often *cannot* meaningfully fix there. This PR
re-surfaces a validation error onto the subgraph host node **when — and
only when — the error's subject (the specific input/widget named by
`extra_info.input_name`) is exposed through the subgraph boundary**.

## Why this is needed

Two concrete situations motivated this, both observed in real workflows:

1. **Broken link at the host.** Root node A should feed subgraph host B,
whose boundary input is linked to interior node C. If the A→B link is
missing, the backend flattens the prompt, sees C with no resolved input,
and raises `required_input_missing` **on C** (`"B:C"`). The actual fix —
connect B's input — is one level up, on a node the error never points
at.
2. **Host-owned widget values.** Per ADR 0009 (subgraph promoted widgets
use linked inputs), a promoted widget's value is owned by the host
`SubgraphNode`; the interior widget only supplies schema/defaults. When
the backend raises `value_not_in_list` (or min/max violations) for that
value, attributing it to the interior node is factually wrong — the
value that failed validation *is the host's value*.

This continues the direction of #13059, which moved **missing-model**
detection identity to `{hostExecutionId, hostWidgetName}` with the
interior path kept as diagnostics. That was possible in the FE pre-scan;
this PR applies the same ownership principle to **backend-received**
errors via a receive-side mapping, since the backend cannot know about
subgraph boundaries in a flattened prompt.

## The rule (design)

> Lift an error from interior node N to host H **iff** N's input slot
named by the error is linked to the containing subgraph's boundary
(`SubgraphInput`). Apply the same test again at H (boundary-by-boundary,
matching ADR 0009's chaining principle) and stop at the first level
where the subject is no longer boundary-linked — that node is where the
user can actually fix it.

The predicate is **structural (boundary exposure), not data-flow**.

### In scope — examples

- `required_input_missing` on interior `"12:5"` whose input is fed by
the boundary → surfaces on host `12`'s input slot (red slot ring on the
host, errors-tab card titled/located at the host, message names the
host's `SubgraphInput.name`).
- `value_not_in_list` / `value_smaller_than_min` /
`value_bigger_than_max` on a promoted interior widget → surfaces on the
host's promoted widget. Nested hosts chain: `"1:2:3"` lifts to `"1:2"`,
and further to `"1"` only if `1:2`'s own slot is boundary-linked too.
- Clearing follows the surface: connecting the highlighted host input or
fixing the host widget clears the underlying interior (raw) error —
range-guarded per target, so a still-out-of-range host value does
**not** clear.

### Out of scope — examples

- **No value-flow ancestry.** All in the root graph: A's widget links to
B, B's to C, and C rejects the value that originated at A → the error
**stays on C**. Following same-graph links to a "root cause" node is
explicitly not this feature.
- Errors without an `input_name` subject, node-level types
(`exception_during_validation`, `dependency_cycle`, image-not-loaded),
and unknown validation types — never lifted. Unknown types stay
node-scoped to match how the error catalog renders them (the shared
`isNodeLevelValidationError` in `executionErrorUtil` encodes this, and
the catalog derives its node-level rules from the same set).
- Runtime execution errors (exceptions during a run) — validation
responses only.
- Interior errors whose input is fed by another interior node — fixable
in place, stay in place.
- Fan-out display dedupe: when one boundary input feeds multiple
interior nodes and the host slot is unconnected, each interior error
lifts to the same host slot as a separate panel line. A single fix
(connecting the host input) clears all of them — the clearing
translation already fans out — so the duplication is cosmetic.
Display-level dedupe is a follow-up; deduping inside the lift would
break the one-source-per-error clearing contract.
- Reactive re-lifting on graph topology edits while errors are displayed
(invariant documented on the computed; follow-up), and deriving the
error catalog's full validation rule table from the shared
classification (follow-up; the node-level type set and the
image-not-loaded predicate are already single-sourced in
`executionErrorUtil` and consumed by both the lift and the catalog).

## Changes

- **What**: New pure module
`core/graph/subgraph/liftNodeErrorsToBoundary.ts` — per-error, fail-open
record transform (unresolvable ids/slots/links leave the error where the
backend put it; raw payload is never mutated). `executionErrorStore`
derives `surfacedNodeErrors` from it and display consumers switch over
(errors tab grouping, canvas node/slot flags, Vue node badges,
app-mode/linear hints); raw `lastNodeErrors` remains the source of truth
for mutation. Host-side clearing translates through the lift's
diagnostics fields (`source_execution_id` / `source_input_name`) with a
per-target range guard.
- **Breaking**: None. No persistence/serialization changes; interior
identity survives as diagnostics metadata only (ADR 0009 language).

## Review Focus

- The lift predicate lives entirely on link topology
(`LLink.originIsIoNode` → `SubgraphInput`) — no
`proxyWidgets`/promotion-store style source authority is reintroduced.
- `clearSlotErrorsWithRangeCheck` now resolves clear targets first and
range-checks each target's raw errors; the lifted-path twin of the
existing range-retention test pins this.
- `useProcessedWidgets` deliberately stays on the raw record: host
promoted widgets already map to interior errors via
`widget.sourceExecutionId`, so an interior widget keeps its local red
hint when the user opens the subgraph (hint layer vs surface layer).
- Unit coverage is carried by the pure module (real litegraph subgraph
fixtures, incl. nested recursion, promoted widgets via
`promoteValueWidgetViaSubgraphInput`, ordering, fail-open/no-mutation);
one e2e pins the user-visible contract (host ring + host slot dot +
interior clean).

## Screenshots

### Before 


https://github.com/user-attachments/assets/81e5c4db-515d-4f1f-8f8a-e07ac490510f

### After



https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
2026-07-14 14:39:20 +00:00
AustinMroz
13fd07201c Merge branch 'main' into pysssss/product-coachmarks 2026-07-13 12:34:39 -07:00
pythongosssss
62806d5ebd Merge branch 'main' into pysssss/product-coachmarks 2026-07-09 12:32:53 +01:00
pythongosssss
dff76dbc9d make button grow, match design 2026-07-08 03:38:48 -07:00
pythongosssss
66b9f329ce update wording 2026-07-08 03:22:56 -07:00
pythongosssss
cb1be63fb6 decrease padding 2026-07-08 03:17:09 -07:00
pythongosssss
3f5b71a472 refactor: use reka FocusScope for coachmark focus trapping
- Replace the custom two-subtree focus trap with reka FocusScope; the
  spotlight overlay is fully modal so a single-subtree trap suffices
- Focus the primary action via a template ref once it's actionable, keyed
  off waitingForTarget, and keep a global Escape-to-skip
- Add an e2e test asserting focus lands on the primary and Tab stays trapped
2026-07-07 07:24:49 -07:00
pythongosssss
7df585e606 flush -> nextTick 2026-07-06 12:47:08 -07:00
pythongosssss
f1f7c53fbe feat: rework coachmark assets step and add step-back navigation
- Auto-open the assets sidebar tab on the assets step instead of
  requiring a click on the Assets button (drops advanceOnTargetClick,
  skipIfMounted, the click-through blocker, and the idle pulse)
- Make the tour overlay fully modal and add a Back button to steps
- Cache the top-bar inset instead of reading it every animation frame
- Reset waitingForTarget when a deferred step aborts
- Dedupe the assets-panel coachmark binding in LinearView
2026-07-06 11:33:43 -07:00
pythongosssss
25ce1ebbe4 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks
# Conflicts:
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts
#	src/platform/telemetry/types.ts
2026-07-06 08:15:27 -07:00
pythongosssss
97308500e9 fix: keep the coachmark tour alive when outputs clear
- Split triggers into holds/autoOpen: only leaving app mode or the
  desktop layout dismisses a running tour, not losing outputs
- Trap focus in the spotlight target only on click-to-advance steps,
  matching the pointer blocker
- Skip non-laid-out elements in the focus cycle
- Drop unused CoachId[] support
- Recompress the landing image
2026-07-06 06:48:28 -07:00
pythongosssss
ccb3cd428b fix: restore the coachmark landing's md max-width
Removing md:max-w-[800px] let the DialogContent variant's sm:max-w-xl
(576px) cap the 800px landing card; tailwind-merge does not dedupe
max-w across breakpoint modifiers.
2026-07-06 05:37:37 -07:00
pythongosssss
9a71b54d05 refactor: move the coachmark tour state machine into a Pinia store
- Convert useCoachmarkTour to onboardingTourStore; replace the
  coachmarkController event hook with a direct store.replayTour() action
- End an active tour (without the seen-flag) when its trigger stops
  holding, e.g. leaving app mode mid-tour
- Toast an error and report skip_reason telemetry (user / target_timeout /
  trigger_lost) when a tour is skipped
- Disable primary buttons while waiting on a deferred target; rename
  suspendFocusGuard to waitingForTarget
- Rename useFocusTrap to useCoachmarkFocusTrap; while suspended, leave
  Tab to the mounting UI but keep the Escape bail-out
- Only auto-open tours on desktop-width (md+) layouts
- Park the registry's rAF poll while no candidate element is registered,
  resuming reactively on registration
- Extract TOUR_SEEN_SETTING and use COACH_IDS constants over string
  literals; move scrim/ring/pulse styles to design-system tokens
- Port tour tests to the store, cover Done in the e2e spec
2026-07-06 03:58:58 -07:00
pythongosssss
61756e977b fix missing modal backdrop 2026-07-03 12:50:35 -07:00
pythongosssss
7630cc9314 fix: use the generic Skip label on the coachmark tour landing
- drop the landing's "Skip for now" override so it falls back to "Skip"
- landing skip already ends the tour and marks it seen (no behavior change)
- unit tests build i18n from the real en locale instead of inline messages
- e2e covers the real auto-open path via a pre-seeded empty seen setting
- trim redundant test comments
2026-07-03 10:52:02 -07:00
pythongosssss
bb39a51d46 fix: narrow the coachmark i18n exemption to derived step keys
- only onboardingCoachmarks.<tour>.<step>.* is dynamically built;
  top-level keys (stepLabel, next, done, skip) stay checked
2026-07-02 06:15:25 -07:00
pythongosssss
76abe8eb3f Merge branch 'main' into pysssss/product-coachmarks 2026-07-02 13:59:01 +01:00
pythongosssss
9bcfda88f6 refactor: reconcile v-coachmark from the element's own data-coach-id
- a single sync() replaces the per-hook register/unregister logic;
  mounted/updated/unmounted all reconcile desired vs current id
- drops the oldValue bookkeeping and its explanatory comment
2026-07-02 05:54:52 -07:00
pythongosssss
e7cdfc8c35 chore: ignore derived coachmark keys in the unused-i18n check
- tour step keys are built as onboardingCoachmarks.<tour>.<name>.*, so
  the literal-key scan can't see them
2026-07-02 05:47:10 -07:00
pythongosssss
e97746fd16 refactor: address coachmark tour PR feedback
- derive step translation keys from a step `name`
  (onboardingCoachmarks.<tour>.<name>.*), with te()-checked
  primary/skip label overrides falling back to Next/Done/Skip
- move all tour i18n into the engine; TourOverlay and TourSpotlight
  now receive translated title/body strings
- replace the landing's required open model with a skip emit and
  drop the landingOpen writable computed
- simplify TourOverlay.test.ts mocks with fromPartial
- replace SCRIM_COLOR with Tailwind classes (bg-black/60,
  spotlight spread shadow)
- narrow the app-run-button anchor so the spotlight excludes the
  run error warning
- key the e2e tour fixture's replay button by tour name
- drop the tour spec's template loading; the test server's default
  workflow already populates the graph (locally: pnpm dev:test)
- prune comments that restated code
2026-07-02 05:39:08 -07:00
pythongosssss
549200a76c fix: address coachmark tour stacking, telemetry, and fixture review notes
- clear the spotlight's previous ZIndex entry before re-raising, so step
  changes stop leaking entries into the shared modal stacking sequence
- report tour telemetry as the user-visible numbering: 1-based step_number
  within counted spotlight steps, omitted for the landing
- seed the e2e seen-tours setting from TOURS so future tours can't
  auto-open under unrelated suites
2026-07-02 01:31:08 -07:00
pythongosssss
96c2ae1182 feat: add landing image to the app-mode tour
- Show public/assets/images/app-mode-landing.png on the welcome landing's
  left panel
2026-07-01 10:19:44 -07:00
pythongosssss
2312b213ce refactor: address coachmark tour review feedback
- Drop the ?coach= query param: remove the forced-entry/replay-any override
  and delayed force-start; tours now start only via auto-open or an explicit
  request. E2E replays via the in-app help button after entering app mode
- Flatten coachmarkController to plain requestTour/onTourRequested exports
  instead of a useCoachmarkController composable
- Derive the top-bar safe inset from the --comfy-topbar-height token plus
  CARD_GAP instead of hardcoding 56
- Remove the landing Start button's fixed width
- Document the real cause of the landing Escape workaround (global keybinding
  preventDefaults Escape before Reka's DismissableLayer dismisses)
- Trim verbose comments in coachmarkRegistry and TourOverlay tests
2026-07-01 10:07:51 -07:00
pythongosssss
ce8b107322 fix: correct coachmark tour skip telemetry and one-shot forcing
- Report the timed-out deferred step in skip telemetry by advancing the
  step index before ending the tour, instead of logging the prior step
- Make the ?coach= override one-shot: named paths force-start only via the
  delayed timer (respecting START_DELAY_MS) and re-entry honors the
  seen-flag again, rather than bypassing it all session
- Resolve waitForTarget to false immediately when the signal is already
  aborted, so it can't hang until timeout
- Reuse the cached dialog locator in Tour.cardForStep
2026-07-01 09:14:15 -07:00
pythongosssss
831813a9db fix: stop coachmark spotlight polling once its target settles
- Drive Floating UI autoUpdate manually so animationFrame polling runs
  only while a deferred target is still moving, then fall back to
  scroll/resize listeners instead of polling every frame for the whole step
- Set aria-modal to false on interaction steps where the user must click
  outside the card
- Ignore unrecognized ?coach= values; keep `any` as the replay keyword
- Align the spotlight scrim with the landing backdrop (0.62 -> 0.6) to
  avoid a dim shift on landing -> spotlight
- Export COACH_IDS and import it in the drift guard instead of a hardcoded
  in-sync list
- Clarify why the landing needs an explicit Escape listener (Reka's
  DismissableLayer doesn't fire update:open here)
- Test that the started telemetry event omits step_index/coach_id while
  per-step events include them

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:43:19 -07:00
pythongosssss
64d10da9d7 refactor: place coachmark cards with Floating UI
- Replace the hand-rolled card-placement math (resolvePlacement /
  cardCorner / clampCardPosition) and the manual target tracking (scroll
  listener + useElementBounding + rAF settle loop) with @floating-ui/vue
  (offset/flip/shift + autoUpdate); promote it from a transitive to a
  direct dependency.
- Keep vertically-centred (leftCenter) cards on-screen with
  shift({ crossAxis: true }), and centre the card when its target hasn't
  laid out, so a step never renders off-screen or invisible.
- Make targetMounted/waitForTarget layout-aware (poll per frame) so a
  deferred target that registers before it sizes resolves only once
  measurable, matching what the spotlight actually displays.
- Shrink the spotlight glow (pad 8->4) so it no longer spills onto an
  adjacent control the user might click.
- Label the spotlight dialog with aria-labelledby pointing at its heading
  instead of a duplicated aria-label.
- Drop the unused isActive option from useFocusTrap.
- Add an e2e guard that walks every app-mode spotlight step and asserts
  each card sits fully within the viewport; add unit coverage for
  last-step Skip hiding, modal z-index reclaim, and all four onboarding
  telemetry stages.
2026-07-01 06:20:03 -07:00
pythongosssss
3f84d4f5f2 test: cover forced tour start, button labels, and spotlight escape
Raise useCoachmarkTour.ts to full line coverage and TourSpotlight.vue escape handling with behavioral tests.
2026-07-01 03:45:44 -07:00
pythongosssss
5383e23d24 Merge origin/main into pysssss/product-coachmarks
Conflict in LinearControls.vue: main refactored the run-button test id into a
named constant (linearRunButtonTestId, same 'linear-run-button' value), while
this branch added the v-coachmark anchor and a static test id to the same
sections. Kept the coachmark directive and adopted main's :data-testid binding,
dropping the now-redundant static id.
2026-07-01 02:57:44 -07:00
pythongosssss
9b8dd27f3d refactor: gate coachmark tour listeners behind an active-step component
Extract TourSpotlight, rendered only while a spotlight step is shown, so the
geometry/scroll, focus-trap and click-to-advance listeners (plus z-index and the
stall pulse) register for the active step rather than the whole graph-view
session. useCoachmarkTour slims to the state machine; the registry-query helpers
(targetMounted/waitForTarget) move to coachmarkRegistry and useCoachmarkTarget
becomes geometry-only and self-measuring.

Also from review feedback:
- read the ?coach= force in setup, before the immediate auto-open watcher, so an
  already-populated app no longer drops a ?coach=any replay
- harden isEntryPath against prototype keys (Object.hasOwn)
- decouple each tour's auto-open condition into useTourTriggers, keeping the
  engine tour-agnostic
2026-06-30 14:00:43 -07:00
pythongosssss
a818b7eee8 test: open the template browser in the coachmark drift guard
Trimming the graph-mode anchors earlier removed the templates-button
click that loadTemplate relied on to open the browser; open it via the
Comfy.BrowseTemplates command instead.
2026-06-30 12:48:49 -07:00
pythongosssss
87d0a110cd fix: close the coachmark landing on Escape explicitly
Reka's built-in Escape dismissal proved unreliable for this dialog in
e2e (Skip, which sets open=false directly, works); close via the same
model path on Escape so the welcome landing reliably dismisses.
2026-06-30 12:41:24 -07:00
pythongosssss
b65da23915 refactor: slim coachmark target tracking and spotlight chrome
- Track the spotlight target with VueUse useElementBounding instead of a
  hand-rolled measure/RAF/ResizeObserver loop; keep a capture-phase scroll
  listener so it still follows targets inside scrollable panels
- Fold the spotlight ring into the dim element (CSS outline + box-shadow),
  dropping the separate SVG; the idle pulse now animates outline-color
- Remove the unused CoachStep `elevated` field and its plumbing
2026-06-30 12:07:22 -07:00
pythongosssss
07356e3253 tidy 2026-06-30 11:19:58 -07:00
pythongosssss
51182127f3 feat: auto-open app-mode tour for populated apps
- Open the app-mode tour when entering an app with linear controls
  visible (mode === 'app' && hasOutputs), including when the overlay
  mounts into one already (immediate watch)
- Respect the seen-flag, so it won't reopen once completed or skipped;
  skip the empty/welcome state and arrange (builder) mode
- Move test target DOM cleanup into afterEach so appended nodes are
  removed even if a test fails early
2026-06-30 10:49:42 -07:00
pythongosssss
fdfa9882b1 feat: make app-mode tour explicit-only and refine assets step
- Open the tour only via the help button or ?coach= param (no auto-open
  when entering app mode)
- Drop the loadTemplate step tech and the demo card image — the tour runs
  on the user's existing app
- Skip the assets-button step at tour start when the assets panel is
  already open; step indicator counts 4 instead of 5
2026-06-30 08:27:53 -07:00
pythongosssss
88cd848245 fix test 2026-06-30 04:39:29 -07:00
pythongosssss
5dbb560ef0 refactor + tidy 2026-06-30 04:35:24 -07:00
pythongosssss
f973626ebc improve test coverage 2026-06-30 03:21:59 -07:00
pythongosssss
e9729ca272 drop blankCanvas tour 2026-06-30 02:44:00 -07:00
pythongosssss
c51f963ef2 trim comments 2026-06-30 02:20:03 -07:00
pythongosssss
5e23f76642 fix: address coachmark tour review feedback
- onboardingTours: use 'auto' placement for assets panel so the card follows the sidebar side
- TourOverlay: clamp no-target card left to the viewport margin so it never goes off-screen
- useCoachmarkTour: catch onPrimary action failures, surface a toast, and only advance on success
- useCoachmarkTour: claim the single-instance guard synchronously so a duplicate mount stays inert
- useFocusTrap: send Shift+Tab from outside the trap to the last item instead of skipping it
- telemetry: drop dead run-button imports left over from the rebase (superseded by getRunButtonTelemetryProperties)
2026-06-30 02:02:27 -07:00
pythongosssss
f642384674 change dash stroke to solid 2026-06-30 02:02:27 -07:00
GitHub Action
f80deb9655 [automated] Apply ESLint and Oxfmt fixes 2026-06-29 21:57:11 +00:00
pythongosssss
75af0430fc - change dialog to use standard component
- move tour trigger to linear controls section
2026-06-29 14:48:18 -07:00
pythongosssss
cc74e1dc65 add assets step to app mode tour 2026-06-29 14:48:18 -07:00
pythongosssss
989773995a feat: add product coachmark onboarding tours
- add blank canvas (demo) and app mode (wip) tours
- overlay with target highlight, landing card, step state handling, step card
- v-coachmark directive updating registry for mount/unmount
- frame settling watcher for animated targets (dialogs)
- focus trap for the target plus the coachmark element
- add telemetry for each step
2026-06-29 14:48:18 -07:00
362 changed files with 35302 additions and 5434 deletions

9
.fallowrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"entry": ["src/main.ts", "index.html"],
"duplicates": {
"minOccurrences": 3,
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
},
"rules": {}
}

3
.gitattributes vendored
View File

@@ -6,3 +6,6 @@ packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
packages/ingest-types/src/types.gen.ts linguist-generated=true
packages/ingest-types/src/zod.gen.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
# Vendored upstream workflow templates used as resolver test fixtures
src/renderer/extensions/firstRunTour/__fixtures__/templates/*.json linguist-generated=true

View File

@@ -73,8 +73,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +93,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +150,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
run: pnpm exec playwright test --project=${{ matrix.browser }}
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report

View File

@@ -23,6 +23,10 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -49,12 +53,15 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -103,6 +110,7 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ yarn.lock
.eslintcache
.prettiercache
.stylelintcache
.fallow/
node_modules
.pnpm-store

View File

@@ -88,6 +88,11 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/composables/useWorkspaceUI',
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:

View File

@@ -10,11 +10,13 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
...LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
)
)
),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
])
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')

View File

@@ -115,14 +115,8 @@ test.describe('Affiliates landing — desktop interactions', () => {
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
// 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 firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
await firstQuestion.click()

View File

@@ -0,0 +1,51 @@
import { expect } from '@playwright/test'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
import { test } from './fixtures/blockExternalMedia'
test.describe('Brand portal @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/brand')
})
test('renders each brand guideline section', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'One mark, many dimensions.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Every color earns its place.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Precise, never cute.' })
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Trademark guidelines.' })
).toBeVisible()
})
test('shows all four marks', async ({ page }) => {
const logos = page.locator('#logos')
for (const name of [
'Core Logo',
'Logomark',
'Icon',
'Amplified Logomark'
]) {
await expect(logos.getByText(name, { exact: true })).toBeVisible()
}
})
test('the hero ctas open the gated guidelines and the logo bundle', async ({
page
}) => {
await expect(
page.getByRole('link', { name: 'View brand guidelines' })
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
await expect(
page.getByRole('link', { name: 'Download logos' })
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
})
})

View File

@@ -87,11 +87,11 @@ test.describe('Cloud page @smoke', () => {
await expect(cards).toHaveCount(3)
})
test('FAQSection heading is visible with 12 items', async ({ page }) => {
test('FAQSection heading is visible with 15 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(12)
await expect(faqButtons).toHaveCount(15)
})
})

View File

@@ -70,58 +70,4 @@ 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,37 +30,4 @@ 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

@@ -1,353 +0,0 @@
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')
})
})
test.describe('Education landing — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('emits FAQPage structured data with one entry per FAQ', async ({
page
}) => {
const faqJsonLd = await page.evaluate(() => {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]'
)
)
const match = scripts.find((s) =>
(s.textContent ?? '').includes('FAQPage')
)
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
})
test('FAQ items toggle open and closed on click', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: FIRST_FAQ.question.en
})
await firstQuestion.scrollIntoViewIfNeeded()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
// 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

@@ -0,0 +1,112 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
})
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
page
}) => {
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
await expect(installLinks).toHaveCount(2)
for (const link of await installLinks.all()) {
await expect(link).toHaveAttribute('href', '#setup')
}
})
test('setup section shows both install options', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
// Claude Code is the default tab and carries the CLI command
await expect(
setup.getByRole('tab', { name: 'Claude Code' })
).toHaveAttribute('data-state', 'active')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
})
test('skills plugin link lives in the agent option card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
})
test('capabilities section shows all six tool cards', async ({ page }) => {
for (const title of [
'Generate anything',
'Search the ecosystem',
'Run real workflows',
'Direct any model',
'Generate in batches',
'Ship it as an app'
]) {
await expect(
page.getByRole('heading', { name: title, exact: true })
).toBeVisible()
}
})
test('FAQ lists nine questions and autolinks the server URL', async ({
page
}) => {
const triggers = page.locator('[id^="faq-trigger-"]')
await triggers.first().scrollIntoViewIfNeeded()
await expect(triggers).toHaveCount(9)
await page.getByRole('button', { name: "What's the server URL?" }).click()
await expect(
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
).toHaveAttribute('href', MCP_ENDPOINT)
})
})
test.describe('MCP page zh-CN @smoke', () => {
test('setup section renders localized options', async ({ page }) => {
await page.goto('/zh-CN/mcp')
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(setup.getByText('方式一')).toBeVisible()
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
})

View File

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

After

Width:  |  Height:  |  Size: 51 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: 73 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,4 @@
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,55 +0,0 @@
<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,20 +1,57 @@
<script setup lang="ts">
import Accordion from '../ui/accordion/Accordion.vue'
import AccordionContent from '../ui/accordion/AccordionContent.vue'
import AccordionItem from '../ui/accordion/AccordionItem.vue'
import AccordionTrigger from '../ui/accordion/AccordionTrigger.vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
defineProps<{
id?: string
const { faqs } = defineProps<{
heading: string
faqs: readonly Faq[]
}>()
type AnswerPart = { type: 'text' | 'link'; value: string }
function parseAnswer(answer: string): AnswerPart[] {
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
const parts: AnswerPart[] = []
let lastIndex = 0
for (const match of answer.matchAll(urlPattern)) {
const start = match.index ?? 0
const url = match[0].replace(/[.,;:]+$/, '')
if (start > lastIndex) {
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
}
parts.push({ type: 'link', value: url })
lastIndex = start + url.length
}
if (lastIndex < answer.length) {
parts.push({ type: 'text', value: answer.slice(lastIndex) })
}
return parts
}
const parsedFaqs = computed(() =>
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
)
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 :id class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
@@ -26,23 +63,72 @@ defineProps<{
</div>
<!-- Right FAQ list -->
<Accordion type="multiple" class="flex-1">
<AccordionItem
v-for="(faq, index) in faqs"
<div class="flex-1">
<div
v-for="(faq, index) in parsedFaqs"
:key="faq.id"
:value="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
<AccordionTrigger :class="index === 0 ? 'pt-0' : ''">
{{ faq.question }}
</AccordionTrigger>
<AccordionContent>
<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"
v-html="faq.answer"
/>
</AccordionContent>
</AccordionItem>
</Accordion>
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
>
<template
v-for="(part, partIndex) in faq.answerParts"
:key="partIndex"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
>{{ part.value }}</a
>
<template v-else>{{ part.value }}</template>
</template>
</p>
</section>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,120 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string }
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
| {
type: 'video'
src: string
@@ -20,6 +20,7 @@ type RowMedia =
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
export interface FeatureRow {
@@ -58,7 +59,7 @@ const {
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
@@ -72,10 +73,11 @@ const {
</div>
<!-- Media: image or video -->
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
<div
:class="
cn(
'order-1 flex lg:w-1/2',
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
@@ -86,7 +88,12 @@ const {
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
class="aspect-4/3 w-full rounded-4xl object-cover"
:class="
cn(
'absolute inset-0 size-full rounded-4xl',
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
)
"
/>
<VideoPlayer
v-else
@@ -99,7 +106,13 @@ const {
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
:fit="row.media.fit"
:class="
cn(
'absolute inset-0 size-full',
row.media.fit === 'contain' && 'bg-transparent'
)
"
/>
</div>
</GlassCard>

View File

@@ -1,97 +0,0 @@
<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

@@ -0,0 +1,20 @@
<script setup lang="ts">
const { resources } = defineProps<{
resources: { label: string; href: string; display: string }[]
}>()
</script>
<template>
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
<li v-for="resource in resources" :key="resource.href">
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
<span class="text-primary-warm-gray"> </span>
<a
:href="resource.href"
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
>
{{ resource.display }}
</a>
</li>
</ul>
</template>

View File

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

View File

@@ -85,6 +85,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
links: [
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('nav.brand', locale), href: routes.brand },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
@@ -175,10 +176,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
</div>
</footer>
</template>

View File

@@ -10,6 +10,7 @@ import {
whenever
} from '@vueuse/core'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import type { HTMLAttributes } from 'vue'
import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
@@ -30,7 +31,9 @@ const {
autoplay = false,
loop = false,
minimal = false,
hideControls = false
hideControls = false,
fit = 'cover',
class: className
} = defineProps<{
locale?: Locale
src?: string
@@ -40,6 +43,8 @@ const {
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
class?: HTMLAttributes['class']
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -189,7 +194,12 @@ function toggleFullscreen() {
<template>
<div
ref="playerEl"
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
:class="
cn(
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
className
)
"
@pointermove="showControls"
@pointerdown="showControls"
@focusin="showControls"
@@ -197,7 +207,9 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
class="size-full object-cover"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"
:src
:poster
:preload="autoplay ? 'auto' : 'metadata'"

View File

@@ -4,24 +4,16 @@ 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
@@ -42,26 +34,18 @@ 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,
AtAGlance,
AuthorBio,
Download,
EducationCta,
Embed,
Video
Steps
}
---
<section class="max-w-9xl mx-auto px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
<section class="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,7 +1,9 @@
<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 }>()
@@ -22,26 +24,83 @@ 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>
<ScrollCarousel :locale="locale">
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<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"
ref="trackRef"
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
>
<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) }}
<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>
<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>
</ScrollCarousel>
<!-- 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>
</template>

View File

@@ -1,56 +0,0 @@
<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,23 +1,62 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type { StoryCard as StoryCardType } from '../../utils/customers'
import StoryCard from './StoryCard.vue'
import { t } from '../../i18n/translations'
import type { StoryCard } from '../../utils/customers'
const { stories, locale = 'en' } = defineProps<{
stories: StoryCardType[]
stories: StoryCard[]
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"
>
<StoryCard
<a
v-for="story in stories"
:key="story.slug"
:story="story"
:locale="locale"
/>
: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>
</section>
</template>

View File

@@ -1,29 +0,0 @@
---
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

@@ -1,60 +0,0 @@
---
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

@@ -1,19 +0,0 @@
---
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

@@ -1,23 +0,0 @@
---
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

@@ -1,22 +0,0 @@
---
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,15 +6,14 @@ 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" />
{
(hasCaptionSlot || caption) && (
caption && (
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
{hasCaptionSlot ? <slot /> : caption}
{caption}
</figcaption>
)
}

View File

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

View File

@@ -1,15 +0,0 @@
---
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,20 +1,16 @@
---
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>
{
name && (
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
)
}
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
</blockquote>

View File

@@ -1,22 +0,0 @@
---
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,16 @@
<script setup lang="ts">
import Button from '../ui/button/Button.vue'
const { href, label } = defineProps<{
href: string
label: string
}>()
</script>
<template>
<div class="mt-2 flex justify-center">
<Button as="a" :href variant="default" size="lg">
{{ label }}
</Button>
</div>
</template>

View File

@@ -1,12 +1,25 @@
<script setup lang="ts">
const { title } = defineProps<{ title: string }>()
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { title, class: className } = defineProps<{
title: string
class?: HTMLAttributes['class']
}>()
</script>
<template>
<section
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
>
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
<h1
:class="
cn(
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
className
)
"
>
{{ title }}
</h1>
</section>

View File

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

View File

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

View File

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

View File

@@ -1,42 +0,0 @@
<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

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

View File

@@ -1,90 +1,60 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type {
PlanFeatureGroup,
PlanFeatureStatus
} from '../../data/pricingPlans'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { BookOpen, Check, Clock, X } from '@lucide/vue'
import { cn } from '@comfyorg/tailwind-utils'
import { t } from '../../i18n/translations'
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'
interface PlanFeature {
text: TranslationKey
}
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[]
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
locale?: Locale
}>()
</script>
<template>
<div class="flex flex-col gap-5">
<div
v-for="(group, groupIndex) in features"
:key="group.titleKey ?? groupIndex"
class="flex flex-col gap-2"
<p
v-if="featureIntroKey"
:class="cn('text-primary-comfy-canvas text-sm font-semibold', introMargin)"
>
{{ t(featureIntroKey, locale) }}
</p>
<ul :class="listGap">
<li
v-for="feature in features"
:key="feature.text"
class="flex items-start gap-2"
>
<p v-if="group.titleKey" class="text-sm text-primary-comfy-canvas">
{{ t(group.titleKey, locale) }}
</p>
<ul class="space-y-2">
<li
v-for="feature in group.features"
:key="feature.text"
class="flex items-start gap-2"
>
<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>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-primary-comfy-canvas text-sm">
{{ t(feature.text, locale) }}
</span>
</li>
</ul>
<p v-if="nextUpKey" :class="nextUpClass">
{{ t(nextUpKey, locale) }}
</p>
<p v-if="andMoreKey" :class="andMoreClass">
{{ t(andMoreKey, locale) }}
</p>
</template>

View File

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

View File

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

@@ -1,238 +0,0 @@
<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

@@ -1,49 +0,0 @@
<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

@@ -1,171 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import type { PlanFeatureGroup } from './PricingPlanFeatureList.vue'
import { computed, ref } from 'vue'
import { Component as ComponentIcon } from '@lucide/vue'
import { subscribeUrl } from '../../data/pricingPlans'
import {
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,7 @@ 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',
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'
'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'
}
},
defaultVariants: {

View File

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

View File

@@ -20,6 +20,8 @@ export const buttonVariants = cva(
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
underlineLink:
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
inline:
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
navMuted:
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'

View File

@@ -1,95 +0,0 @@
<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

@@ -1,119 +0,0 @@
<script setup lang="ts">
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import {
SliderRange,
SliderRoot,
SliderThumb,
SliderTrack,
useForwardPropsEmits
} from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const {
class: className,
ticks,
min = 0,
max = 100,
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

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ const baseRoutes = {
customers: '/customers',
demos: '/demos',
learning: '/learning',
education: '/education',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',
@@ -22,7 +21,8 @@ const baseRoutes = {
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp'
mcp: '/mcp',
brand: '/brand'
} as const
type Routes = typeof baseRoutes
@@ -78,7 +78,6 @@ 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',
@@ -89,12 +88,11 @@ export const externalLinks = {
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpEndpoint: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
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,12 +63,8 @@ function bodySectionIds(body: string): string[] {
const stories = loadStories()
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)
}
it('finds all ten customer stories', () => {
expect(stories).toHaveLength(10)
})
describe.for(stories)('$file', ({ frontmatter, body }) => {

View File

@@ -1,148 +0,0 @@
---
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

@@ -1,149 +0,0 @@
---
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

@@ -1,138 +0,0 @@
---
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

@@ -1,56 +0,0 @@
---
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

@@ -1,103 +0,0 @@
---
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

@@ -1,5 +1,7 @@
import type { LocalizedText } from '../i18n/translations'
import { BRAND_ASSETS_ZIP } from './brandAssets'
interface AffiliateBrandAsset {
id: string
title: LocalizedText
@@ -7,9 +9,6 @@ interface AffiliateBrandAsset {
preview: string
}
const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
{
id: 'core-logo',

View File

@@ -0,0 +1,9 @@
// Shared brand download URLs served from the media bucket, used by both the
// affiliate page and the brand portal.
export const BRAND_ASSETS_ZIP =
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
export const BRAND_GUIDELINES_PDF =
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'

View File

@@ -0,0 +1,91 @@
interface BrandColor {
name: string
hex: string
rgb: string
hsl: string
cmyk: string
swatchClass: string
textClass: string
wide?: boolean
border?: boolean
}
export const brandColors: readonly BrandColor[] = [
{
name: 'Comfy Yellow',
hex: '#F2FF59',
rgb: '242, 255, 89',
hsl: '65, 100, 67',
cmyk: '5, 0, 65, 0',
swatchClass: 'bg-primary-comfy-yellow',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Comfy Ink',
hex: '#211927',
rgb: '33, 25, 39',
hsl: '274, 22, 13',
cmyk: '15, 36, 0, 85',
swatchClass: 'bg-primary-comfy-ink',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Comfy Canvas',
hex: '#C2BFB9',
rgb: '194, 191, 185',
hsl: '40, 7, 74',
cmyk: '0, 2, 5, 24',
swatchClass: 'bg-primary-comfy-canvas',
textClass: 'text-primary-comfy-ink'
},
{
name: 'Comfy Plum',
hex: '#49378B',
rgb: '73, 55, 139',
hsl: '253, 43, 38',
cmyk: '47, 60, 0, 45',
swatchClass: 'bg-primary-comfy-plum',
textClass: 'text-primary-comfy-canvas'
},
{
name: 'Warm White',
hex: '#F0EFED',
rgb: '240, 239, 237',
hsl: '40, 9, 94',
cmyk: '0, 0, 1, 6',
swatchClass: 'bg-primary-warm-white',
textClass: 'text-primary-comfy-ink',
wide: true
},
{
name: 'Warm Gray',
hex: '#7E7C78',
rgb: '126, 124, 120',
hsl: '40, 2, 48',
cmyk: '0, 2, 5, 51',
swatchClass: 'bg-primary-warm-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Cool Gray',
hex: '#3C3C3C',
rgb: '60, 60, 60',
hsl: '0, 0, 24',
cmyk: '0, 0, 0, 76',
swatchClass: 'bg-secondary-cool-gray',
textClass: 'text-primary-warm-white',
border: true
},
{
name: 'Mauve',
hex: '#4D3762',
rgb: '77, 55, 98',
hsl: '271, 28, 30',
cmyk: '21, 44, 0, 62',
swatchClass: 'bg-secondary-mauve',
textClass: 'text-primary-warm-white'
}
] as const

View File

@@ -1,178 +0,0 @@
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

@@ -1,43 +0,0 @@
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,11 +129,6 @@ 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

@@ -1,227 +0,0 @@
import type { LocalizedText } from '../i18n/translations'
interface PricingFaq {
id: string
question: LocalizedText
answer: LocalizedText
}
export const pricingFaqs: readonly PricingFaq[] = [
{
id: 'how-does-pricing-work',
question: {
en: 'How does Comfy Cloud pricing actually work?',
'zh-CN': 'Comfy Cloud 的定价究竟是如何运作的?'
},
answer: {
en: "Every plan includes a monthly pool of <strong>credits</strong>. Credits are spent on two things: <strong>active GPU time</strong> while a workflow is running, and <strong>Partner Nodes</strong> (proprietary models like Nano Banana Pro). You're never charged for idle time. Building or editing a workflow costs nothing. You only spend while a job is actually running.",
'zh-CN':
'每个计划都包含每月的<strong>积分</strong>池。积分用于两类消耗:工作流运行时的<strong>活跃 GPU 时间</strong>,以及<strong>合作伙伴节点</strong>(如 Nano Banana Pro 等专有模型)。空闲时间不会计费。构建或编辑工作流完全免费。只有任务真正运行时才会扣费。'
}
},
{
id: 'what-is-a-credit-worth',
question: {
en: "What's a credit worth? How far does it go?",
'zh-CN': '一个积分价值多少?能用多久?'
},
answer: {
en: 'Credits map to GPU runtime, so mileage depends on the workflow. As a reference point, a five-second video* uses roughly <strong>11 credits</strong>, so Standard covers a few hundred per month, Creator about double that, and Pro enough for over a thousand.\n\n*Based on 5s videos using the Wan 2.2 Image-to-Video template at default settings (81 frames, 18fps, 640×640, 4-step sampler). Heavier models, higher resolutions, and the inclusion of Partner nodes use more.',
'zh-CN':
'积分对应 GPU 运行时长,因此具体能用多少取决于工作流本身。作为参考:一段 5 秒视频*大约消耗 <strong>11 积分</strong>,因此 Standard 每月可支持数百段Creator 约为其两倍Pro 则足以生成一千多段。\n\n*基于使用 Wan 2.2 图生视频模板在默认设置81 帧、18fps、640×640、4-step sampler下生成 5 秒视频的估算。更复杂的模型、更高分辨率以及加入合作伙伴节点会消耗更多积分。'
}
},
{
id: 'run-out-of-credits',
question: {
en: 'What happens when I run out of credits?',
'zh-CN': '积分用完了会怎样?'
},
answer: {
en: 'You can buy <strong>top-up credits</strong> at any time without changing plans. Monthly credits are spent first; top-ups are only drawn down once your monthly allowance is used up. Top-up credits stay valid for <strong>1 year</strong> from purchase.',
'zh-CN':
'您可以随时购买<strong>充值积分</strong>,无需更换计划。月度积分会优先消耗;只有当月度额度用完后,才会开始使用充值积分。充值积分自购买之日起 <strong>1 年</strong>内有效。'
}
},
{
id: 'do-credits-roll-over',
question: {
en: 'Do unused credits roll over?',
'zh-CN': '未使用的积分会顺延吗?'
},
answer: {
en: "Monthly plan credits reset each billing cycle and don't roll over. <strong>Top-up credits do persist.</strong> They're valid for a year and aren't affected by your monthly reset. Credits work on Comfy Cloud, and on Comfy Desktop <strong>only when calling Partner Nodes.</strong> Comfy Desktop itself is free.",
'zh-CN':
'月度计划积分在每个计费周期重置,不会顺延。<strong>但充值积分会保留。</strong>有效期为一年,且不受每月重置的影响。积分可在 Comfy Cloud 上使用,在 Comfy 桌面版上<strong>仅在调用合作伙伴节点时使用</strong>。Comfy 桌面版本身免费。'
}
},
{
id: 'difference-between-plans',
question: {
en: "What's the difference between Standard, Creator, and Pro?",
'zh-CN': 'Standard、Creator 和 Pro 有什么区别?'
},
answer: {
en: '<strong>Standard -</strong> 30-min max runtime per workflow, 1 concurrent workflow via API. For individuals building workflows.\n\n<strong>Creator -</strong> Everything in Standard plus the ability to <strong>import your own models</strong> (from CivitAI or Hugging Face) and run up to 3 workflows concurrently via API.\n\n<strong>Pro -</strong> Everything in Creator plus <strong>longer runtime (up to 1 hour)</strong> per workflow and up to 5 concurrent workflows via API. For teams running Comfy in production.',
'zh-CN':
'<strong>Standard -</strong> 单个工作流最长运行 30 分钟API 支持 1 个并发工作流。适合构建工作流的个人。\n\n<strong>Creator -</strong> 包含 Standard 的全部功能,并新增<strong>导入自有模型</strong>(来自 CivitAI 或 Hugging Face的能力API 支持最多 3 个并发工作流。\n\n<strong>Pro -</strong> 包含 Creator 的全部功能,并提供<strong>更长的运行时长(最长 1 小时)</strong>API 支持最多 5 个并发工作流。适合在生产环境中运行 Comfy 的团队。'
}
},
{
id: 'how-does-team-plan-work',
question: {
en: 'How does the Team Plan work?',
'zh-CN': '团队计划是如何运作的?'
},
answer: {
en: 'The Team Plan puts your whole team on <strong>one shared credit pool.</strong> Every member draws from the same balance, so you\'re not juggling separate subscriptions. Key things to know:\n\n<strong>One pool, shared.</strong> Everyone generates against the same credit balance.\n<strong>Invite by email.</strong> Add teammates, and resend or revoke access any time.\n<strong>Owners manage billing.</strong> Assign owners who handle payment and buy top-ups for the team.\n<strong>Upgrade in place.</strong> Move an existing workspace to a team and your workflows, models, and assets stay attached.\n\nChoose your monthly credit commitment that fits your team. <a href="https://cloud.comfy.org/?pricing=team" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Get started today.</a>',
'zh-CN':
'团队计划让整个团队共享<strong>一个积分池</strong>。每位成员都从同一余额中扣费,无需分别管理多个订阅。要点:\n\n<strong>一池共享。</strong>所有人都从同一积分余额中生成内容。\n<strong>邮箱邀请。</strong>添加团队成员,随时重新发送或撤销访问权限。\n<strong>所有者管理账单。</strong>指定所有者负责付款,并为团队购买充值积分。\n<strong>原地升级。</strong>将现有工作区升级为团队工作区,您的工作流、模型和资产都将保留。\n\n选择适合您团队的每月积分承诺。<a href="https://cloud.comfy.org/?pricing=team" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">立即开始。</a>'
}
},
{
id: 'team-spending-controls',
question: {
en: 'Can I control how my team spends credits?',
'zh-CN': '我可以控制团队消耗积分的方式吗?'
},
answer: {
en: "Today, owners control the shared pool and top-ups. We're actively building finer-grained controls: <strong>spending limits</strong> at the user, project, and workspace level, <strong>per-project budgets and chargebacks</strong>, <strong>auto-recharge</strong> when the pool runs low, and <strong>self-serve teams beyond 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

@@ -1,46 +0,0 @@
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

@@ -1,151 +0,0 @@
import type { TranslationKey } from '../i18n/translations'
import { SHOW_FREE_TIER } from '../config/features'
import { externalLinks } from '../config/routes'
export type BillingCycle = 'monthly' | 'yearly'
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

@@ -1,64 +0,0 @@
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`
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { externalLinks, getRoutes } from '../config/routes'
const routes = getRoutes('en')
const resources = [
{
label: 'Learning Center',
href: routes.learning,
display: 'comfy.org/learning'
},
{
label: 'Workflow templates',
href: externalLinks.workflows,
display: 'comfy.org/workflows'
},
{
label: 'Customer stories',
href: routes.customers,
display: 'comfy.org/customers'
},
{
label: 'Docs',
href: externalLinks.docs,
display: 'docs.comfy.org'
}
]
---
<BaseLayout
title="You're booked - Comfy"
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
noindex
>
<HeroSection title="You're booked." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
>
<p>Check your email for the calendar invite and meeting link!</p>
<div class="flex flex-col gap-4">
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
Resources while you wait
</h2>
<ResourceList resources={resources} />
</div>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,28 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import BrandBackground from '../templates/brand/BrandBackground.vue'
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
---
<BaseLayout
title={t('brand.page.title', locale)}
description={t('brand.page.description', locale)}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection />
<BrandLogosSection />
<BrandColorSection client:visible />
<BrandVoiceSection />
<BrandTrademarkSection />
<BrandQuestionsSection />
</div>
</BaseLayout>

View File

@@ -1,8 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PricingSection from '../../components/pricing/PricingSection.vue'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import FAQSection from '../../components/pricing/FAQSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
@@ -38,7 +37,6 @@ const productId = jsonLdId(url, 'product')
}),
]}
>
<PricingSection client:load />
<PriceSection client:load />
<WhatsIncludedSection />
<FAQSection client:visible />
</BaseLayout>

View File

@@ -5,39 +5,13 @@ 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"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: collectionLabel }
]}
extraJsonLd={[storyList]}
>
<BaseLayout title="Customer Stories - Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -3,15 +3,8 @@ 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')
@@ -22,37 +15,9 @@ 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`}
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]}
>
<BaseLayout title={`${entry.data.title} - Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -12,6 +12,7 @@ import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../utils/jsonLd'
@@ -30,7 +31,7 @@ const { siteUrl, locale } = pageContext(
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -1,64 +0,0 @@
---
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 { educationFaqs } from '../data/educationFaq'
import { t } from '../i18n/translations'
import { isEducationStory, toCardProps } from '../utils/customers'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { loadStories } from '../utils/loadStories'
const locale = 'en' as const
// Creative Campus stories power the education carousel.
const stories = (await loadStories(locale))
.map(toCardProps)
.filter((story) => isEducationStory(story.slug))
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: educationFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
}
---
<BaseLayout
title={t('education.page.title', locale)}
description={t('education.page.description', locale)}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={escapeJsonLd(faqJsonLd)}
/>
</Fragment>
<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

@@ -0,0 +1,60 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { getRoutes } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout
title="Thanks for reaching out - Comfy"
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
noindex
>
<HeroSection title="Thanks for reaching out." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
Based on what you shared, one of our self-serve plans is probably a
better fit.
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>,
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>, and
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
> for individual creators, plus our new
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> plan for multiple users under shared billing.
</p>
<PlansPricingCta
href={routes.cloudPricing}
label="See plans and pricing →"
/>
<p class="text-primary-warm-gray mt-8 text-sm">
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
Need help with something else? Email: <a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -0,0 +1,54 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
import { externalLinks, getRoutes } from '../../config/routes'
const routes = getRoutes('zh-CN')
const resources = [
{
label: '学习中心',
href: routes.learning,
display: 'comfy.org/learning'
},
{
label: '工作流模板',
href: externalLinks.workflows,
display: 'comfy.org/workflows'
},
{
label: '客户案例',
href: routes.customers,
display: 'comfy.org/customers'
},
{
label: '文档',
href: externalLinks.docs,
display: 'docs.comfy.org'
}
]
---
<BaseLayout
title="预约成功 - Comfy"
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
noindex
>
<HeroSection title="预约成功。" class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
>
<p>请查收邮件中的日历邀请和会议链接!</p>
<div class="flex flex-col gap-4">
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
等待期间的资源
</h2>
<ResourceList resources={resources} />
</div>
</div>
</section>
</BaseLayout>

Some files were not shown because too many files have changed in this diff Show More