Compare commits

..

20 Commits

Author SHA1 Message Date
huang47
81332a3dca test: probe informational Codecov status 2026-07-17 15:29:37 -07:00
huang47
d4b1ced906 ci: make Codecov patch coverage informational 2026-07-17 13:27:56 -07:00
Maanil Verma
d791351a1c feat(subscription): unify credit gold across indicators and subscribe buttons (#13763)
## Summary

Credit indicators and subscribe CTAs now share one gold, so the credit
balance, the amounts in pricing and checkout, and the buttons that
upgrade or top up all read as a single system. The subscribe buttons
also drop the old purple gradient for a solid gold that is actually
legible.

## Changes

**What**

- Recolor the credit accent to a single gold value and point every
credit surface at that one token, replacing the scattered amber and gold
used before.
- Repaint the subscribe buttons: they were a multi layer purple gradient
with faint white text (nearly unreadable), and are now a solid gold fill
with dark text. Hover dims slightly instead of shifting the gradient.
- Rename the button `subscribe` variant (it used to be called
`gradient`, which stopped being accurate once it became a flat fill) and
delete the now unused gradient token so there is no dead styling left
behind.
- Swap the credit cost glyphs across pricing tables, subscription
success and preview screens, the free tier quota, the partner node
popover, and both user popovers so they match the recolored token rather
than raw amber.
- Built on top of #13712 

**Breaking**

None. The subscribe buttons keep the same public API (the shared
`SubscribeButton` still exposes a `buttonVariant` prop, now typed
`'default' | 'subscribe'`), and no behavior, routing, or telemetry
changes.

## Testing

This is a token and styling change with no logic, so the meaningful
verification is visual plus the standard gates, not new unit tests (a
test asserting a hex value would just be a change detector).

<img width="507" height="557" alt="image"
src="https://github.com/user-attachments/assets/fed73d12-e3cc-46a6-aae2-db37f24d9bbb"
/>
2026-07-18 00:37:26 +05:30
Mobeen Abdullah
922be31b74 feat(website): rename Comfy Hub to Comfy Workflows in user-facing copy (#13718)
## Summary

Marketing-site half of the **Comfy Hub → Comfy Workflows** rename
([GTM-271](https://linear.app/comfyorg/issue/GTM-271/comfy-workflows-rename-site-docs-copylogo-sweep));
the hub-side sweep is Comfy-Org/workflow_templates#1015. Silent copy
change, links keep pointing at comfy.org/workflows.

## Changes

- **What**: renames the six user-facing "Comfy Hub" mentions to "Comfy
Workflows": the Community nav item, the homepage/models showcase card
title, the models-page "COMFY HUB" eyebrow, the "Browse Comfy Hub" link
(en + zh-CN), and the two supported-models sentences (body copy + FAQ
JSON-LD text).
- Internal identifiers unchanged per the rename PRD non-goals (the
`nav.comfyHub` i18n key stays).

## Review Focus

- The supported-models copy also feeds the FAQPage JSON-LD;
`validate:jsonld` passes on all 503 pages.
- zh-CN strings keep the brand in Latin script ("Comfy Workflows"),
matching how "Comfy Hub" was handled.

## Verification

- typecheck 0 errors · unit 199/199 · oxfmt clean · build 500 pages ·
validate:jsonld 503 pages · built HTML contains zero remaining "Comfy
Hub".

## Screenshots (if applicable)

Attached below (Community nav dropdown, models-page showcase section).
2026-07-17 18:19:41 +00:00
Angelo Lesniak
e6c04359b5 fix: load mask editor layers from the saved image's subfolder (#13744)
## Summary

Since #12318 the mask editor uploads its layer files to the input root,
but the loader still looked for them in the `clipspace/` subfolder, so
reopening the editor after saving a mask silently failed (four 404s,
`[MaskEditorContent] Initialization failed`, dialog never opens).
Affects v1.47.3+.

## Changes

- **What**: `useMaskEditorLoader` derives the layer-file subfolder from
the image the widget references (input root for saves under the unified
upload contract, `clipspace/` for saves made before #12318) instead of
hardcoding `clipspace`. When layer files are missing entirely, it falls
back to loading the node image (mask restored from its alpha channel) so
the editor still opens instead of failing silently.

## Review Focus

- The cloud path (`/files/mask-layers`) now also resolves layer refs
with the widget ref's subfolder instead of hardcoded `clipspace`.
Verified against a local OSS backend (save → reopen round-trip, plus
masks saved by 1.45.x with `clipspace/`-prefixed widget values and by
1.47.x with root-stored files); not verified against cloud.
- Repro/verification: unit tests and the new e2e round-trip test fail on
`main` and pass with this change.
2026-07-17 09:45:21 -04:00
Deep Mehta
27859cb41c feat(website): section-level client tabs on /mcp setup with click tracking (#13731)
## Summary

Stacked on #13728. Implements the setup-section redesign from the design
mock: the client picker moves out of the Option 1 card into a bordered
tab tray directly under the section header, and the selected client
drives the step + command shown inside the Option 1 (manual install)
card.

## Changes

- **What**:
- Tabs render at section level in a bordered tray (`rounded-2xl border
p-1`, yellow fill-active pills); `TabsRoot` wraps the tray and the grid
so the panels stay inside the manual card.
- Added OpenClaw as a sixth client (skill install + `openclaw mcp set`
commands from the setup docs); the catch-all tab is renamed to "Others".
- The agent card's skills note now sits inline under the command instead
of pinned to the card bottom.
- PostHog: new `website:mcp_client_tab_clicked` event (property:
`client`) captured on tab click via the existing `posthog.ts` helper
pattern; two unit tests added alongside the download-click tests.
Heatmap/autocapture data comes for free once on the page, this gives
named click-volume per client.
- e2e: OpenClaw tab assertion added; all 7 mcp tests pass; card heights
verified stable across all six tabs.

## Review Focus

- Event-capture verification in headless browsers is structurally
impossible (posthog-js drops events for `navigator.webdriver` clients),
so runtime verification is unit-level, matching how
`captureDownloadClick` is tested.
- Copy kept from #13728 (subtitle lists manual first; agent card leads
"Prefer to let your agent do it?") rather than the mock's carried-over
screenshot text.

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

## Screenshots (if applicable)
2026-07-17 08:47:18 +00:00
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
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
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
150 changed files with 5710 additions and 1015 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": {}
}

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

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

@@ -0,0 +1,153 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
// The setup island hydrates on visibility; clicks before hydration are
// no-ops, so retry until the tab actually activates.
async function selectClientTab(setup: Locator, name: string) {
const tab = setup.getByRole('tab', { name })
await expect(async () => {
await tab.click()
await expect(tab).toHaveAttribute('data-state', 'active', { timeout: 500 })
}).toPass()
}
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('Claude Desktop is the default tab and shows only the connector card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('tab', { name: 'Claude Desktop' })
).toHaveAttribute('data-state', 'active')
await expect(
setup.getByRole('heading', { name: 'Add Custom Connector' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
})
test('client tabs swap install instructions and agent-card visibility', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
const agentHeading = setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
await expect(activePanel).toContainText('Add custom connector')
// First interaction retries until the island hydrates; later switches
// assert synchronously so steady-state click regressions fail.
await selectClientTab(setup, 'Claude Code Terminal')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
await expect(agentHeading).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
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 expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'OpenClaw' }).click()
await expect(activePanel).toContainText(
'openclaw skills install @comfy-org/comfy'
)
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Others' }).click()
await expect(activePanel).toContainText('remote MCP server')
await expect(agentHeading).toBeVisible()
})
test('skills plugin link lives in the agent option card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await selectClientTab(setup, 'Claude Code Terminal')
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.getByRole('heading', { name: '添加自定义连接器' })
).toBeVisible()
await selectClientTab(setup, 'Claude Code Terminal')
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})
})

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,6 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { reactive, watch } from 'vue'
import { computed, reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
@@ -9,6 +9,31 @@ const { faqs } = defineProps<{
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(
@@ -40,7 +65,7 @@ function toggle(index: number) {
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
v-for="(faq, index) in parsedFaqs"
:key="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
@@ -83,8 +108,23 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
<p
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>

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

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

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

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,10 @@ const {
autoplay = false,
loop = false,
minimal = false,
hideControls = false
hideControls = false,
fit = 'cover',
ariaLabel,
class: className
} = defineProps<{
locale?: Locale
src?: string
@@ -40,6 +44,9 @@ const {
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
ariaLabel?: string
class?: HTMLAttributes['class']
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -88,6 +95,28 @@ watch(videoEl, syncNativeDuration)
useEventListener(videoEl, 'loadedmetadata', syncNativeDuration)
useEventListener(videoEl, 'durationchange', syncNativeDuration)
// The muted attribute only sets defaultMuted, so SSR-rendered autoplay
// videos count as unmuted and get blocked; force the property and kick
// playback. Scoped to hideControls (decorative) clips so chrome-visible
// consumers keep native semantics. flush: 'post' guarantees this runs
// after useMediaControls' internal muted watcher on the same source.
watch(
[videoEl, () => src],
([el]) => {
if (!el || !autoplay || !hideControls) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el.pause()
return
}
el.muted = true
el.play().catch((error: unknown) => {
if (error instanceof Error && error.name === 'AbortError') return
console.warn('VideoPlayer autoplay failed', error)
})
},
{ flush: 'post' }
)
const effectiveDuration = computed(() => duration.value || nativeDuration.value)
// Scrubber (modeled after VueUse demo Scrubber.vue)
@@ -189,7 +218,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 +231,10 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
class="size-full object-cover"
:aria-label="ariaLabel"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"
:src
:poster
:preload="autoplay ? 'auto' : 'metadata'"

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

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

@@ -21,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
@@ -87,6 +88,7 @@ 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',

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

@@ -83,8 +83,8 @@ const translations = {
'如果您是 ComfyUI 新手,可以从 App 模式开始——这是工作流的简化视图。您随时可以切换回节点图视图以深入了解。'
},
'showcase.feature3.title': {
en: 'Community Workflows on Comfy Hub',
'zh-CN': 'Comfy Hub 上的社区工作流'
en: 'Community Workflows on Comfy Workflows',
'zh-CN': 'Comfy Workflows 上的社区工作流'
},
'showcase.feature3.description': {
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template and customize it to your needs.',
@@ -1864,10 +1864,26 @@ const translations = {
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPrompt': {
'mcp.hero.demoPromptMoodboard': {
en: 'turn the brief in this email into a 6-up moodboard',
'zh-CN': '把这封邮件里的需求做成六宫格情绪板'
},
'mcp.hero.demoPromptConcepts': {
en: 'sketch three concept frames for the launch page',
'zh-CN': '为发布页画三张概念稿'
},
'mcp.hero.demoPromptKeyart': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.demoPromptPbr': {
en: 'make a tileable asphalt PBR material, all 5 maps',
'zh-CN': '生成可平铺的沥青 PBR 材质,共 5 张贴图'
},
'mcp.hero.demoPromptUpscale': {
en: 'upscale the neon kaiju shot to 4K',
'zh-CN': '把霓虹怪兽画面放大到 4K'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
@@ -1876,10 +1892,6 @@ const translations = {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
@@ -1897,60 +1909,100 @@ const translations = {
'zh-CN': '放大图像'
},
// MCP SetupStepsSection
// MCP SetupSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
en: 'Set up Comfy MCP',
'zh-CN': '配置 Comfy MCP'
},
'mcp.setup.subtitle': {
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
en: 'Two ways to connect: add the server yourself, or ask your agent to install it. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
'两种接入方式:自行添加服务器,或让你的智能体自动安装。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
'mcp.setup.manual.title': {
en: 'Install manually',
'zh-CN': '手动安装'
},
'mcp.setup.manual.description': {
en: 'Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'zh-CN':
'将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
},
'mcp.setup.manual.tabsLabel': {
en: 'Pick your client',
'zh-CN': '选择你的客户端'
},
'mcp.setup.agent.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
'mcp.setup.agent.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.step1.description': {
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'mcp.setup.agent.description': {
en: 'Prefer to let your agent do it? Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
'想让智能体代劳?将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Or add it by hand',
'zh-CN': '或手动添加'
'mcp.setup.clients.claudeCode.step': {
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
},
'mcp.setup.step2.description': {
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'mcp.setup.walkthroughAlt': {
en: '{client} setup walkthrough',
'zh-CN': '{client} 设置演示'
},
'mcp.setup.clients.claudeDesktop.manualTitle': {
en: 'Add Custom Connector',
'zh-CN': '添加自定义连接器'
},
'mcp.setup.clients.claudeDesktop.step': {
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加自定义连接器。文档涵盖各类客户端。'
'点击侧边栏的 Customize进入 Connectors选择添加自定义连接器,粘贴上方 URL 并登录。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
'zh-CN': 'COMFY CLOUD MCP 文档'
'mcp.setup.clients.cursor.step': {
en: 'Add the URL above to ~/.cursor/mcp.json with an X-API-Key header. Create your key at ',
'zh-CN':
'将上方 URL 添加到 ~/.cursor/mcp.json并附带 X-API-Key 请求头。在此创建密钥:'
},
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
'mcp.setup.clients.cursor.linkLabel': {
en: 'platform.comfy.org',
'zh-CN': 'platform.comfy.org'
},
'mcp.setup.step3.description': {
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
'mcp.setup.clients.codex.step': {
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
},
'mcp.setup.step3.cta': {
en: 'COMFY CLOUD SKILLS',
'zh-CN': 'COMFY CLOUD 技能'
'mcp.setup.clients.openclaw.step': {
en: 'Run these in your terminal, then openclaw mcp login comfy to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 openclaw mcp login comfy 登录。'
},
'mcp.setup.clients.other.name': {
en: 'Others',
'zh-CN': '其他'
},
'mcp.setup.clients.other.step': {
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
'zh-CN':
'将上方 URL 添加为远程 MCP 服务器。客户端不支持 OAuth改用 X-API-Key 请求头。完整教程见'
},
'mcp.setup.clients.other.linkLabel': {
en: 'setup docs',
'zh-CN': '设置文档'
},
'mcp.setup.skillsNote': {
en: 'Using Claude Code? The Comfy skills plugin adds ready-made slash commands. ',
'zh-CN': '在用 Claude CodeComfy 技能插件提供现成的斜杠命令。'
},
'mcp.setup.skillsLink': {
en: 'View on GitHub',
'zh-CN': '在 GitHub 上查看'
},
// MCP WhyBuildSection
@@ -1971,9 +2023,9 @@ const translations = {
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
en: 'MCP is an open standard, so any MCP-compatible client can connect. Claude Code, Claude Desktop, and Codex sign in with OAuth; every other agent connects with an API key.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude CodeClaude Desktop,更多客户端即将推出。'
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。Claude CodeClaude Desktop 和 Codex 通过 OAuth 登录,其他智能体使用 API 密钥连接。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
@@ -2037,14 +2089,53 @@ const translations = {
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
en: 'Submit graphs, track jobs, and pull outputs back. Save and share workflows, reuse a saved one, or open any run on the ComfyUI canvas — the full engine, driven by tool calls.',
'zh-CN':
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
'提交计算图、跟踪任务并取回输出。保存和分享工作流,复用已保存的工作流,或在 ComfyUI 画布上打开任意运行——完整的引擎,由工具调用驱动。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
'mcp.tools.4.title': {
en: 'Direct any model',
'zh-CN': '直接调用任意模型'
},
'mcp.tools.4.description': {
en: 'Kling, Veo, Seedance, Flux, GPT-Image, Nano Banana, and ElevenLabs. Closed partner APIs and open-source models, reached through one set of tools.',
'zh-CN':
'Kling、Veo、Seedance、Flux、GPT-Image、Nano Banana 和 ElevenLabs。封闭的合作伙伴 API 与开源模型,通过同一套工具即可调用。'
},
'mcp.tools.4.alt': {
en: 'Comfy MCP directing closed partner APIs and open-source models through one set of tools',
'zh-CN': 'Comfy MCP 通过同一套工具调用封闭合作伙伴 API 和开源模型'
},
'mcp.tools.5.title': {
en: 'Generate in batches',
'zh-CN': '批量生成'
},
'mcp.tools.5.description': {
en: 'Stack a batch on the Queue, track it, and pull back every output. Dozens of runs from a single call.',
'zh-CN':
'将一批任务加入队列,跟踪进度,并取回每一个输出。一次调用即可完成数十次运行。'
},
'mcp.tools.5.alt': {
en: 'Comfy MCP stacking a batch on the Queue and pulling back every output',
'zh-CN': 'Comfy MCP 将一批任务加入队列并取回每个输出'
},
'mcp.tools.6.title': {
en: 'Ship it as an app',
'zh-CN': '作为应用发布'
},
'mcp.tools.6.description': {
en: 'Turn any workflow into an app with a shareable URL. Collaborators run it in the browser — only the inputs you expose, nothing to install.',
'zh-CN':
'将任意工作流变成带可分享链接的应用。协作者在浏览器中运行——只暴露你开放的输入,无需安装任何东西。'
},
'mcp.tools.6.alt': {
en: 'Comfy MCP turning a workflow into a shareable browser app',
'zh-CN': 'Comfy MCP 将工作流变成可在浏览器中分享的应用'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
@@ -2091,71 +2182,81 @@ const translations = {
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
en: "For Claude Code, Claude Desktop, or Codex, add https://cloud.comfy.org/mcp as a custom connector or remote MCP server in any client, then sign in when prompted.\nFor clients that don't support OAuth, connect with a Comfy API key. Send the docs https://docs.comfy.org/agent-tools/cloud to your agent and it will figure out the installation for you.",
'zh-CN':
'目前支持 Claude CodeClaude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
'对于 Claude CodeClaude Desktop 或 Codex在任意客户端中将 https://cloud.comfy.org/mcp 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。\n对于不支持 OAuth 的客户端,请使用 Comfy API 密钥连接。将文档 https://docs.comfy.org/agent-tools/cloud 发送给你的智能体,它会为你完成安装。'
},
'mcp.faq.2.q': {
en: "What's the server URL?",
'zh-CN': '服务器 URL 是什么?'
},
'mcp.faq.2.a': {
en: 'https://cloud.comfy.org/mcp — add it as a custom connector or remote MCP server in any client, then sign in when prompted.',
'zh-CN':
'https://cloud.comfy.org/mcp——在任意客户端中将它添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。'
},
'mcp.faq.3.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
en: 'Not for Claude Code, Claude Desktop, Codex, or OpenClaw. You need a Comfy API key for Cursor and Hermes for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
'Claude CodeClaude Desktop、Codex 和 OpenClaw 不需要。Cursor 和 Hermes 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
},
'mcp.faq.4.q': {
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
en: 'Does it cost anything?',
'zh-CN': '需要付费吗?'
},
'mcp.faq.4.a': {
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
en: "Connecting is free with a Comfy account, and searching models, nodes, and templates doesn't cost credits. Running a generation uses Comfy Cloud credits and needs a subscription or credit balance. Your agent confirms with you before it spends.",
'zh-CN':
'在 Claude Code 中,运行 /mcp选择 comfy-cloud,然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
'使用 Comfy 账户连接是免费的,搜索模型、节点和模板也不消耗积分。运行生成会使用 Comfy Cloud 积分,需要订阅或积分余额。智能体在消费前会先与你确认。'
},
'mcp.faq.5.q': {
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接'
en: 'Can I use it with my local ComfyUI?',
'zh-CN': '可以配合我的本地 ComfyUI 使用吗'
},
'mcp.faq.5.a': {
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli: https://github.com/Comfy-Org/comfy-cli',
'zh-CN':
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
'即将推出。目前,若要操作本地 ComfyUI你可以使用 comfy-clihttps://github.com/Comfy-Org/comfy-cli'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.7.a': {
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'mcp.faq.6.a': {
en: "• Generate images, video, audio, and 3D — including all open-source workflows and partner models like Seedance, GPT-Image, Nano Banana, and Kling\n• Build, edit, and run workflows; save and re-run workflows\n• Run and read in large batches\n• Search models, nodes, and template workflows\n• Read and execute shared workflow URLs\n• Upload and download assets for you\n\nEverything is now in natural language. No nodes, no downloads, no GPU, no node graphs if you don't want them.",
'zh-CN':
'生成图像、视频、音频和 3D;搜索模型、节点和模板;并运行 ComfyUI 工作流——全部在对话中完成。'
'生成图像、视频、音频和 3D——包括所有开源工作流以及 Seedance、GPT-Image、Nano Banana 和 Kling 等合作伙伴模型\n• 构建、编辑和运行工作流;保存并重新运行工作流\n• 大批量运行和读取\n• 搜索模型、节点和模板工作流\n• 读取并执行分享的工作流链接\n• 为你上传和下载资产\n\n现在一切都用自然语言完成。如果你愿意无需节点、无需下载、无需 GPU、无需节点图。'
},
'mcp.faq.7.q': {
en: 'Where do my outputs go?',
'zh-CN': '我的输出会保存到哪里?'
},
'mcp.faq.7.a': {
en: 'Into your Comfy Cloud asset library, so you can reuse, remix, and share them — and open any run on the canvas to keep editing. You can also ask your agent to download the assets locally for you.',
'zh-CN':
'保存到你的 Comfy Cloud 资产库,你可以复用、二次创作和分享——还能在画布上打开任意运行继续编辑。你也可以让智能体把资产下载到本地。'
},
'mcp.faq.8.q': {
en: 'Do slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.8.a': {
en: 'No. They ship with the Claude Code comfy-cloud plugin. Desktop connects to the same MCP server, so every tool works; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令随 Claude Code 的 comfy-cloud 插件一起提供。Claude Desktop 连接的是同一个 MCP 服务器,因此所有工具都能使用;直接用自然语言提问即可。'
},
'mcp.faq.9.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
'mcp.faq.9.a': {
en: 'Yes. Comfy Cloud MCP is in open beta and available to everyone with a Comfy account.',
'zh-CN':
'是的。Comfy Cloud MCP 目前处于公开测试阶段,任何拥有 Comfy 账户的人都可以使用。'
},
// SiteNav
@@ -2171,7 +2272,7 @@ const translations = {
en: 'Comfy Enterprise',
'zh-CN': 'Comfy 企业版'
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.comfyHub': { en: 'Comfy Workflows', 'zh-CN': 'Comfy Workflows' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
@@ -2181,6 +2282,7 @@ const translations = {
'nav.youtube': { en: 'YouTube', 'zh-CN': 'YouTube' },
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
@@ -4200,8 +4302,8 @@ const translations = {
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
en: 'COMFY WORKFLOWS',
'zh-CN': 'COMFY WORKFLOWS'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
@@ -4237,9 +4339,9 @@ const translations = {
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Workflows</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Workflows</a> 查看最新工作流。'
},
// Payment status pages
@@ -4446,6 +4548,161 @@ const translations = {
'launches.section.title': {
en: 'Latest Launches',
'zh-CN': '最新发布'
},
// Brand Portal page (/brand)
'brand.page.title': {
en: 'Brand — Comfy',
'zh-CN': '品牌 — Comfy'
},
'brand.page.description': {
en: 'The Comfy brand portal: logos, color, typography, and voice. Everything you need to build something that looks and sounds like Comfy.',
'zh-CN':
'Comfy 品牌门户:标志、色彩、字体与语调。打造与 Comfy 观感一致、表达一致所需的一切。'
},
'brand.hero.label': {
en: 'Brand Portal',
'zh-CN': '品牌门户'
},
'brand.hero.heading': {
en: 'Create with ComfyUI',
'zh-CN': '用 ComfyUI 创作'
},
'brand.hero.subheading': {
en: 'Logo, color, type, and voice. Everything you need to build something that looks and sounds like us.',
'zh-CN': '标志、色彩、字体与语调。打造与我们观感一致、表达一致所需的一切。'
},
'brand.hero.viewGuidelines': {
en: 'View brand guidelines',
'zh-CN': '查看品牌规范'
},
'brand.hero.downloadLogos': {
en: 'Download logos',
'zh-CN': '下载标志'
},
'brand.logos.heading': {
en: 'One mark, many dimensions.',
'zh-CN': '一个标志,多种维度。'
},
'brand.logos.subheading': {
en: 'Logos come in light and dark options. Use as provided. Do not distort, recolor, or outline. Make sure the logo is legible against its background.',
'zh-CN':
'标志提供浅色和深色两种版本。请按原样使用,不要变形、改色或描边。确保标志在其背景上清晰可辨。'
},
'brand.colors.heading': {
en: 'Every color earns its place.',
'zh-CN': '每种颜色都各得其所。'
},
'brand.colors.subheading': {
en: 'Our color palette helps build brand recognition. When people think of Comfy, we want them to associate it with the following colors.',
'zh-CN':
'我们的调色板有助于建立品牌辨识度。当人们想到 Comfy 时,我们希望他们联想到以下这些颜色。'
},
'brand.colors.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'brand.colors.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
'brand.voice.heading': {
en: 'Precise, never cute.',
'zh-CN': '精准,绝不卖弄。'
},
'brand.voice.direct.title': {
en: 'Direct',
'zh-CN': '直接'
},
'brand.voice.direct.body': {
en: 'We state things. We dont hedge, qualify, or suggest. Short sentences. Active voice. One idea at a time.',
'zh-CN':
'我们直陈其事。不含糊、不设限、不暗示。短句。主动语态。一次只讲一个观点。'
},
'brand.voice.precise.title': {
en: 'Precise',
'zh-CN': '精准'
},
'brand.voice.precise.body': {
en: 'We use the real names for things. Nodes, samplers, seeds, checkpoints. We dont talk around the product or reach for metaphor when the technical term is already good.',
'zh-CN':
'我们直呼其名nodes、samplers、seeds、checkpoints。当技术术语已经足够贴切时我们不绕弯子也不借用比喻。'
},
'brand.voice.human.title': {
en: 'Human-first',
'zh-CN': '以人为先'
},
'brand.voice.human.body': {
en: 'The human creates. Comfy makes every step visible. We never write as though the AI is doing the work.',
'zh-CN':
'创作的是人。Comfy 让每一步都清晰可见。我们绝不把功劳写成是 AI 完成的。'
},
'brand.voice.antihype.title': {
en: 'Anti-hype',
'zh-CN': '拒绝浮夸'
},
'brand.voice.antihype.body': {
en: 'We dont write “stunning,” “revolutionary,” or “effortless.” We dont promise magic. Our tagline says exactly what we mean: Method, not magic.',
'zh-CN':
'我们不写“惊艳”“革命性”或“毫不费力”。我们不承诺魔法。我们的口号恰如其分:方法,而非魔法。'
},
'brand.voice.doLabel': {
en: 'Do',
'zh-CN': '推荐'
},
'brand.voice.dontLabel': {
en: 'Dont',
'zh-CN': '避免'
},
'brand.voice.do.0': {
en: 'Route your prompt through a ControlNet. Wire the output to the VAE decode.',
'zh-CN': '让你的 prompt 经过 ControlNet再将输出连接到 VAE decode。'
},
'brand.voice.do.1': {
en: 'Comfy runs on your hardware. Nothing leaves your machine.',
'zh-CN': 'Comfy 在你自己的硬件上运行。任何数据都不会离开你的机器。'
},
'brand.voice.dont.0': {
en: 'Simply connect your AI blocks and watch the magic happen!',
'zh-CN': '只需连接你的 AI 模块,见证奇迹的发生!'
},
'brand.voice.dont.1': {
en: 'Oops! Something went wrong. Please try again later.',
'zh-CN': '哎呀!出了点问题,请稍后再试。'
},
'brand.trademark.heading': {
en: 'Trademark guidelines.',
'zh-CN': '商标使用规范。'
},
'brand.trademark.body1': {
en: 'Comfy and ComfyUI are trademarks of Comfy Org. Youre welcome to reference them in content that accurately describes your work with our platform. Tutorials, reviews, integrations, and affiliate content all qualify.',
'zh-CN':
'Comfy 和 ComfyUI 是 Comfy Org 的商标。欢迎在准确描述你与我们平台相关工作的内容中引用它们。教程、评测、集成以及联盟内容均可。'
},
'brand.trademark.body2': {
en: 'A few rules: dont modify the logo, dont use the Comfy name in your own product or company name, and dont present your content in a way that implies official endorsement or partnership beyond whats been agreed.',
'zh-CN':
'几条规则:不要修改标志,不要在你自己的产品或公司名称中使用 Comfy 这一名称,也不要以暗示官方认可或合作关系(超出双方已达成的约定)的方式呈现你的内容。'
},
'brand.trademark.body3': {
en: 'For permissions outside these guidelines,',
'zh-CN': '如需本规范之外的授权,请'
},
'brand.trademark.contact': {
en: 'Contact Us',
'zh-CN': '联系我们'
},
'brand.questions.heading': {
en: 'Questions?',
'zh-CN': '有疑问?'
},
'brand.questions.body': {
en: 'For press, partnerships, or anything outside these guidelines,',
'zh-CN': '如涉及媒体、合作,或本规范未涵盖的任何事宜,请'
},
'brand.questions.contact': {
en: 'Contact Us',
'zh-CN': '联系我们'
}
} as const satisfies Record<string, Record<Locale, string>>

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

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

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

@@ -44,7 +44,7 @@ const dirDescriptions: Record<string, string> = {
}
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Workflows, ready to load and customize.`
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
@@ -87,7 +87,7 @@ const faqPage: JsonLdNode = {
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Workflows. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
},
},
{

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>

View File

@@ -0,0 +1,26 @@
---
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'
---
<BaseLayout
title={t('brand.page.title', 'zh-CN')}
description={t('brand.page.description', 'zh-CN')}
>
<div class="relative">
<BrandBackground client:idle />
<BrandHeroSection locale="zh-CN" />
<BrandLogosSection locale="zh-CN" />
<BrandColorSection client:visible locale="zh-CN" />
<BrandVoiceSection locale="zh-CN" />
<BrandTrademarkSection locale="zh-CN" />
<BrandQuestionsSection locale="zh-CN" />
</div>
</BaseLayout>

View File

@@ -12,6 +12,7 @@ import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
@@ -33,7 +34,7 @@ const { siteUrl, locale } = pageContext(
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -0,0 +1,57 @@
---
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('zh-CN')
---
<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-6 text-center text-base font-light lg:text-lg"
>
<p>
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>、
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
>,以及我们全新的
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> 套餐,可让多位用户共享账单。
</p>
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
<p class="text-primary-warm-gray mt-8 text-sm">
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<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">
需要其他方面的帮助?邮箱:<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

@@ -78,3 +78,28 @@ describe('captureDownloadClick', () => {
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})
describe('captureMcpClientTabClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the tab click with the client id', async () => {
const { initPostHog, captureMcpClientTabClick } = await import('./posthog')
initPostHog()
captureMcpClientTabClick('claude-code')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:mcp_client_tab_clicked',
{ client: 'claude-code' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureMcpClientTabClick } = await import('./posthog')
captureMcpClientTabClick('cursor')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -49,3 +49,12 @@ export function captureDownloadClick(platform: Platform) {
console.error('PostHog download click capture failed', error)
}
}
export function captureMcpClientTabClick(client: string) {
if (!initialized) return
try {
posthog.capture('website:mcp_client_tab_clicked', { client })
} catch (error) {
console.error('PostHog MCP client tab capture failed', error)
}
}

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import {
useEventListener,
useIntersectionObserver,
useRafFn
} from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
interface Node {
x: number
y: number
vx: number
vy: number
}
const canvasEl = ref<HTMLCanvasElement | null>(null)
let dpr =
typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1
const nodes: Node[] = Array.from({ length: 14 }, () => ({
x: Math.random(),
y: Math.random(),
vx: (Math.random() - 0.5) * 0.0005,
vy: (Math.random() - 0.5) * 0.0005
}))
let ctx: CanvasRenderingContext2D | null = null
function draw() {
const el = canvasEl.value
if (!el || !ctx) return
const w = el.width
const h = el.height
ctx.clearRect(0, 0, w, h)
for (const n of nodes) {
n.x += n.vx
n.y += n.vy
if (n.x < 0 || n.x > 1) n.vx *= -1
if (n.y < 0 || n.y > 1) n.vy *= -1
}
ctx.strokeStyle = 'rgba(242, 255, 89, 0.18)'
ctx.lineWidth = dpr
const max = Math.min(w, h) * 0.22
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i]
const b = nodes[j]
const dx = (a.x - b.x) * w
const dy = (a.y - b.y) * h
const d = Math.hypot(dx, dy)
if (d < max) {
ctx.globalAlpha = 1 - d / max
ctx.beginPath()
ctx.moveTo(a.x * w, a.y * h)
const mx = ((a.x + b.x) / 2) * w
ctx.bezierCurveTo(mx, a.y * h, mx, b.y * h, b.x * w, b.y * h)
ctx.stroke()
}
}
}
ctx.globalAlpha = 1
ctx.fillStyle = 'rgba(242, 255, 89, 0.9)'
for (const n of nodes) {
ctx.beginPath()
ctx.arc(n.x * w, n.y * h, 2.5 * dpr, 0, Math.PI * 2)
ctx.fill()
}
}
// Resizing clears the canvas bitmap, so repaint immediately afterwards to keep
// the field visible even when the RAF loop is paused (off screen or
// reduced-motion). Also refresh dpr in case the window moved to another display.
function resize() {
const el = canvasEl.value
if (!el) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
el.width = el.offsetWidth * dpr
el.height = el.offsetHeight * dpr
draw()
}
const { pause, resume } = useRafFn(draw, { immediate: false })
// Only animate while the field is on screen, and honour reduced-motion by
// painting a single static frame instead of looping.
useIntersectionObserver(canvasEl, ([entry]) => {
if (prefersReducedMotion()) return
if (entry?.isIntersecting) resume()
else pause()
})
useEventListener('resize', resize)
onMounted(() => {
ctx = canvasEl.value?.getContext('2d') ?? null
resize()
})
</script>
<template>
<canvas
ref="canvasEl"
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-screen w-full"
/>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { useClipboard } from '@vueuse/core'
import { computed, ref } from 'vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { brandColors } from '../../data/brandColors'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const specRows = ['hex', 'rgb', 'hsl', 'cmyk'] as const
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
const copiedHex = ref<string | null>(null)
const copiedValue = ref('')
function copyValue(hex: string, value: string) {
copiedHex.value = hex
copiedValue.value = value
void copy(value)
}
function isCardCopied(hex: string) {
return copied.value && copiedHex.value === hex
}
const liveMessage = computed(() =>
copied.value ? `${t('brand.colors.copied', locale)} ${copiedValue.value}` : ''
)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.colors.heading', locale) }}
<template #subtitle>
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
{{ t('brand.colors.subheading', locale) }}
</p>
</template>
</SectionHeader>
<span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
<ul class="mt-10 grid grid-cols-2 gap-4 lg:grid-cols-5">
<li
v-for="color in brandColors"
:key="color.hex"
:class="
cn(
'flex min-h-[123px] cursor-pointer flex-col rounded-[30px] p-6',
color.swatchClass,
color.textClass,
color.wide && 'lg:col-span-2',
color.border && 'border-primary-warm-gray border-[0.783px]'
)
"
@click="copyValue(color.hex, color.hex)"
>
<div
v-if="isCardCopied(color.hex)"
class="flex flex-1 items-center justify-center text-center text-sm font-semibold"
aria-hidden="true"
>
{{ t('brand.colors.copied', locale) }} {{ copiedValue }}
</div>
<template v-else>
<span class="text-xs font-semibold">{{ color.name }}</span>
<dl
class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 text-xs leading-[1.4]"
>
<template v-for="row in specRows" :key="row">
<dt class="uppercase opacity-50">{{ row }}</dt>
<dd>
<button
type="button"
:aria-label="`${t('brand.colors.copy', locale)} ${row} ${color[row]}`"
class="cursor-pointer text-left hover:underline"
@click.stop="copyValue(color.hex, color[row])"
>
{{ color[row] }}
</button>
</dd>
</template>
</dl>
</template>
</li>
</ul>
</section>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import Button from '../../components/ui/button/Button.vue'
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../../data/brandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto px-6 pt-4 pb-10 text-center lg:px-20 lg:pb-12"
>
<p
class="text-primary-comfy-yellow text-sm font-extrabold tracking-[0.7px] uppercase"
>
{{ t('brand.hero.label', locale) }}
</p>
<h1
class="lg:text-6.5xl mx-auto mt-6 max-w-4xl text-4xl leading-[1.3] font-light tracking-[-0.03em] text-primary-comfy-canvas md:text-5xl"
>
{{ t('brand.hero.heading', locale) }}
</h1>
<p
class="mx-auto mt-6 max-w-2xl text-[17px] leading-[1.6] font-light text-primary-comfy-canvas/80"
>
{{ t('brand.hero.subheading', locale) }}
</p>
<div
class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"
>
<Button
as="a"
:href="BRAND_GUIDELINES_PDF"
target="_blank"
rel="noopener noreferrer"
variant="default"
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
>
{{ t('brand.hero.viewGuidelines', locale) }}
</Button>
<Button
as="a"
:href="BRAND_ASSETS_ZIP"
download
variant="outline"
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
>
{{ t('brand.hero.downloadLogos', locale) }}
</Button>
</div>
</section>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const assets = affiliateBrandAssets.map((asset) =>
asset.id === 'icon' ? { ...asset, preview: '/icons/comfyicon.svg' } : asset
)
</script>
<template>
<section id="logos" class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.logos.heading', locale) }}
<template #subtitle>
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
{{ t('brand.logos.subheading', locale) }}
</p>
</template>
</SectionHeader>
<ul class="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<li
v-for="asset in assets"
:key="asset.id"
class="flex min-h-60 flex-col rounded-[30px] border-[1.5px] border-white/8 lg:min-h-[285px]"
>
<div class="flex flex-1 items-center justify-center p-8">
<img
:src="asset.preview"
:alt="asset.title[locale]"
:class="
cn(
'object-contain',
asset.id === 'icon'
? 'size-24 rounded-[23%] border border-white/10'
: 'max-h-24 max-w-[75%]'
)
"
loading="lazy"
decoding="async"
/>
</div>
<p
class="pb-8 text-center text-[21px] font-medium tracking-[1.05px] text-primary-comfy-canvas"
>
{{ asset.title[locale] }}
</p>
</li>
</ul>
</section>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import Button from '../../components/ui/button/Button.vue'
import { externalLinks } from '../../config/routes.ts'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto px-6 pt-10 pb-24 lg:px-20 lg:pt-12 lg:pb-32"
>
<SectionHeader align="start" max-width="xl">
{{ t('brand.questions.heading', locale) }}
</SectionHeader>
<p class="text-primary-warm-gray mt-6 max-w-2xl text-sm leading-[1.6]">
{{ t('brand.questions.body', locale) }}
<Button
as="a"
variant="inline"
:href="externalLinks.support"
target="_blank"
rel="noopener noreferrer"
>
{{ t('brand.questions.contact', locale) }}
</Button>
</p>
</section>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import Button from '../../components/ui/button/Button.vue'
import { externalLinks } from '../../config/routes.ts'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.trademark.heading', locale) }}
</SectionHeader>
<div
class="text-primary-warm-gray mt-6 flex max-w-4xl flex-col gap-4 text-sm leading-[1.6]"
>
<p>{{ t('brand.trademark.body1', locale) }}</p>
<p>{{ t('brand.trademark.body2', locale) }}</p>
<p>
{{ t('brand.trademark.body3', locale) }}
<Button
as="a"
variant="inline"
:href="externalLinks.support"
target="_blank"
rel="noopener noreferrer"
>
{{ t('brand.trademark.contact', locale) }}
</Button>
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import SectionHeader from '../../components/common/SectionHeader.vue'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const principles = [
{
title: t('brand.voice.direct.title', locale),
body: t('brand.voice.direct.body', locale)
},
{
title: t('brand.voice.precise.title', locale),
body: t('brand.voice.precise.body', locale)
},
{
title: t('brand.voice.human.title', locale),
body: t('brand.voice.human.body', locale)
},
{
title: t('brand.voice.antihype.title', locale),
body: t('brand.voice.antihype.body', locale)
}
]
const doExamples = [
t('brand.voice.do.0', locale),
t('brand.voice.do.1', locale)
]
const dontExamples = [
t('brand.voice.dont.0', locale),
t('brand.voice.dont.1', locale)
]
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
<SectionHeader align="start" max-width="xl">
{{ t('brand.voice.heading', locale) }}
</SectionHeader>
<dl class="mt-10 flex max-w-4xl flex-col gap-3.5 text-sm leading-[1.6]">
<div v-for="principle in principles" :key="principle.title">
<dt class="text-primary-comfy-yellow">{{ principle.title }}</dt>
<dd class="text-primary-warm-gray">{{ principle.body }}</dd>
</div>
</dl>
<div class="mt-12 grid gap-4 md:grid-cols-2">
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
<div class="flex items-center gap-2">
<span
class="bg-primary-comfy-yellow size-2.5 rounded-full"
aria-hidden="true"
/>
<span
class="text-sm font-bold tracking-wider text-primary-comfy-canvas uppercase"
>
{{ t('brand.voice.doLabel', locale) }}
</span>
</div>
<div
v-for="example in doExamples"
:key="example"
class="bg-transparency-ink-t80 rounded-2xl p-5"
>
<p class="text-base/[1.45] text-primary-comfy-canvas">
{{ example }}
</p>
</div>
</div>
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
<div class="flex items-center gap-2">
<span
class="bg-primary-warm-gray size-2.5 rounded-full"
aria-hidden="true"
/>
<span
class="text-primary-warm-gray text-sm font-bold tracking-wider uppercase"
>
{{ t('brand.voice.dontLabel', locale) }}
</span>
</div>
<div
v-for="example in dontExamples"
:key="example"
class="bg-transparency-ink-t80 rounded-2xl p-5"
>
<p class="text-primary-warm-gray text-base/[1.45] line-through">
{{ example }}
</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -7,35 +7,40 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
// Each cycle types the prompt that produces the card about to slide in.
const cards = [
{
promptKey: 'mcp.hero.demoPromptMoodboard',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
promptKey: 'mcp.hero.demoPromptConcepts',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
promptKey: 'mcp.hero.demoPromptKeyart',
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
promptKey: 'mcp.hero.demoPromptPbr',
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
promptKey: 'mcp.hero.demoPromptUpscale',
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
@@ -65,15 +70,15 @@ function schedule(fn: () => void, ms: number) {
}, ms)
}
function typePrompt(onDone: () => void) {
function typePrompt(prompt: string, onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
displayedPrompt.value = prompt.slice(0, i)
if (i < prompt.length) {
schedule(step, 35)
} else {
promptDone.value = true
@@ -94,8 +99,8 @@ function revealNextCard() {
return
}
// Type the prompt, then slide in the next card
typePrompt(() => {
// Type the next card's prompt, then slide that card in
typePrompt(t(cards[visibleCount.value].promptKey, locale), () => {
visibleCount.value++
schedule(revealNextCard, 400)
})

View File

@@ -5,7 +5,7 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),

View File

@@ -11,9 +11,10 @@ const ctas = mcpCtas(locale)
</script>
<template>
<!-- 5rem/6.75rem = HeaderMain's rendered height (py-5 / lg:py-8) so the hero fills the viewport below the sticky nav -->
<HeroSplit01
:locale="locale"
class="min-h-screen"
class="min-h-[calc(100svh-5rem)] lg:min-h-[calc(100svh-6.75rem)]"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"

View File

@@ -23,7 +23,7 @@ const steps: FeatureStep[] = stepNumbers.map((n) => ({
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.runWorkflow"
:primary-cta="ctas.installMcp"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -1,69 +1,248 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { computed, ref } from 'vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import VideoPlayer from '../../components/common/VideoPlayer.vue'
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { captureMcpClientTabClick } from '../../scripts/posthog'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
const agentCommand = t('mcp.setup.agent.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
interface McpClient {
id: string
name: string
step: string
command?: string
link?: { label: string; href: string }
manualTitle?: string
showAgentCard: boolean
// Walkthrough clip shown in place of the agent card (source: docs.comfy.org/agent-tools/mcp)
video?: string
}
const clients: McpClient[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
}
id: 'claude-desktop',
name: 'Claude Desktop',
step: t('mcp.setup.clients.claudeDesktop.step', locale),
manualTitle: t('mcp.setup.clients.claudeDesktop.manualTitle', locale),
showAgentCard: false,
video: '/videos/mcp/setup-claude-desktop-v1.mp4'
},
{
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
id: 'claude-code',
name: 'Claude Code Terminal',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`,
showAgentCard: true
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
id: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`,
showAgentCard: false,
video: '/videos/mcp/setup-codex-oauth-v1.mp4'
},
{
id: 'cursor',
name: 'Cursor',
step: t('mcp.setup.clients.cursor.step', locale),
link: {
label: t('mcp.setup.clients.cursor.linkLabel', locale),
href: externalLinks.apiKeys
},
showAgentCard: true
},
{
id: 'openclaw',
name: 'OpenClaw',
step: t('mcp.setup.clients.openclaw.step', locale),
command: `openclaw skills install @comfy-org/comfy\nopenclaw mcp set comfy '{"url":"${externalLinks.mcpEndpoint}","transport":"streamable-http","auth":"oauth"}'`,
showAgentCard: true
},
{
id: 'other',
name: t('mcp.setup.clients.other.name', locale),
step: t('mcp.setup.clients.other.step', locale),
link: {
label: t('mcp.setup.clients.other.linkLabel', locale),
href: externalLinks.docsMcp
},
showAgentCard: true
}
]
const activeClientId = ref(clients[0].id)
const activeClient = computed(
() =>
clients.find((client) => client.id === activeClientId.value) ?? clients[0]
)
const manualTitle = computed(
() => activeClient.value.manualTitle ?? t('mcp.setup.manual.title', locale)
)
// reka-ui re-emits update:modelValue even when the value is unchanged
// (re-clicking the active tab), so dedupe before capturing.
let lastTrackedClientId: string | undefined
function onClientTabChange(value: string | number | undefined) {
if (!value) return
const id = String(value)
if (id === lastTrackedClientId) return
lastTrackedClientId = id
captureMcpClientTabClick(id)
}
const walkthroughLabel = computed(() =>
t('mcp.setup.walkthroughAlt', locale).replace(
'{client}',
activeClient.value.name
)
)
const copyLabel = t('ui.copy', locale)
const copiedLabel = t('ui.copied', locale)
</script>
<template>
<FeatureGrid01
<section
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
>
<SectionHeader
max-width="xl"
:label="t('mcp.setup.label', locale)"
align="start"
>
{{ t('mcp.setup.heading', locale) }}
<template #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ t('mcp.setup.subtitle', locale) }}
</p>
</template>
</SectionHeader>
<TabsRoot
v-model="activeClientId"
activation-mode="manual"
class="mt-10 block"
@update:model-value="onClientTabChange"
>
<TabsList
:aria-label="t('mcp.setup.manual.tabsLabel', locale)"
class="grid grid-cols-1 gap-px rounded-2xl border border-white/15 bg-primary-comfy-ink p-1 min-[360px]:grid-cols-2 lg:inline-flex lg:flex-nowrap"
>
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow shrink-0 cursor-pointer rounded-lg bg-white/8 px-2 py-2.5 text-[10px] font-bold tracking-wider whitespace-nowrap text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink lg:rounded-none lg:px-6 lg:text-xs lg:first:rounded-l-xl lg:last:rounded-r-xl"
>
{{ client.name }}
</TabsTrigger>
</TabsList>
<div class="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
{{ manualTitle }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.manual.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsContent
v-for="client in clients"
:key="client.id"
:value="client.id"
class="mt-6 flex min-h-36 flex-col gap-3"
>
<p class="text-sm text-smoke-700">
{{ client.step
}}<a
v-if="client.link"
:href="client.link.href"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ client.link.label }}</a
>
</p>
<CopyableField
v-if="client.command"
:value="client.command"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</TabsContent>
</div>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl"
:class="
activeClient.showAgentCard
? 'p-6 lg:p-8'
: 'relative overflow-hidden max-lg:aspect-video'
"
>
<template v-if="activeClient.showAgentCard">
<h3
class="text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.agent.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.agent.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</template>
<VideoPlayer
v-else-if="activeClient.video"
:key="activeClient.id"
:locale="locale"
:aria-label="walkthroughLabel"
:src="activeClient.video"
autoplay
loop
hide-controls
fit="contain"
class="absolute inset-0 size-full bg-transparent"
/>
</div>
</div>
</TabsRoot>
</section>
</template>

View File

@@ -7,16 +7,21 @@ import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string }
| { type: 'image'; src: string; fit?: 'cover' | 'contain' }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
const tools: {
n: 1 | 2 | 3 | 4 | 5 | 6
media: ToolMedia
altKey?: TranslationKey
}[] = [
{
n: 1,
media: {
@@ -40,9 +45,38 @@ const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true
hideControls: true,
fit: 'contain'
},
altKey: 'mcp.tools.3.alt'
},
{
n: 4,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/direct-any-model.png'
},
altKey: 'mcp.tools.4.alt'
},
{
n: 5,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-in-batches.png'
},
altKey: 'mcp.tools.5.alt'
},
{
n: 6,
media: {
type: 'video',
src: 'https://media.comfy.org/website/homepage/showcase/ui-overview.webm',
autoplay: true,
loop: true,
hideControls: true,
fit: 'contain'
},
altKey: 'mcp.tools.6.alt'
}
]

View File

@@ -1,4 +1,4 @@
import { externalLinks, getRoutes } from '../../config/routes'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -9,14 +9,13 @@ export interface McpCta {
}
/**
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
* Calls-to-action for the MCP page: view the docs or jump to the on-page
* setup options. Both the hero and the "how it works" section pair install
* with docs.
*/
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
return {
docs: {
@@ -27,10 +26,6 @@ export function mcpCtas(locale: Locale): {
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

View File

@@ -171,6 +171,31 @@ describe('comfyUiSourceCodeNode', () => {
})
})
describe('ComfyUI entity links', () => {
it('names the home page as the canonical page for the application', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.mainEntityOfPage).toBe(`${siteUrl}/`)
})
it('links the application back to the source code emitted alongside it', () => {
const app = comfyUiApplicationNode(siteUrl)
const source = comfyUiSourceCodeNode(siteUrl)
expect(app.isBasedOn).toEqual({ '@id': source['@id'] })
})
it('claims no canonical page or source code for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.mainEntityOfPage).toBeUndefined()
expect(node.isBasedOn).toBeUndefined()
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(

View File

@@ -194,6 +194,8 @@ export interface SoftwareAppInput {
authorName?: string
isFree?: boolean
sameAs?: string[]
mainEntityOfPage?: string
isBasedOnId?: string
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
@@ -219,6 +221,8 @@ export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
mainEntityOfPage: input.mainEntityOfPage,
isBasedOn: input.isBasedOnId ? { '@id': input.isBasedOnId } : undefined,
offers: input.isFree
? {
'@type': 'Offer',
@@ -257,6 +261,10 @@ export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
function comfyUiSourceCodeId(siteUrl: string): string {
return `${siteUrl}/#sourcecode`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
@@ -267,14 +275,16 @@ export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs
sameAs: comfyUiSameAs,
mainEntityOfPage: `${siteUrl}/`,
isBasedOnId: comfyUiSourceCodeId(siteUrl)
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: `${siteUrl}/#sourcecode`,
id: comfyUiSourceCodeId(siteUrl),
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',

View File

@@ -54,6 +54,11 @@
"source": "/press",
"destination": "/about",
"permanent": true
},
{
"source": "/login",
"destination": "https://cloud.comfy.org/login",
"permanent": false
}
]
}

View File

@@ -8,11 +8,13 @@ export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
public readonly dragHandle: Locator
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar-container')
this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
this.dragHandle = this.root.locator('.drag-handle')
}
async isDocked() {

View File

@@ -0,0 +1,21 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class FreeTierQuota {
readonly root: Locator
constructor(comfyPage: ComfyPage) {
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
}
async getMax() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[2]
}
async getAvailable() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[1]
}
}

View File

@@ -58,6 +58,17 @@ class MaskEditorHelper {
return dialog
}
async reopenDialog(): Promise<Locator> {
const imagePreview = this.page.locator('.image-preview').first()
await imagePreview.getByRole('region').hover()
await this.page.getByLabel('Edit or mask image').click()
const dialog = this.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
return dialog
}
async drawStrokeOnPointerZone(dialog: Locator) {
const pointerZone = dialog.getByTestId('pointer-zone')
await expect(pointerZone).toBeVisible()

View File

@@ -103,7 +103,8 @@ export const TestIds = {
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
actionBarButtons: 'action-bar-buttons',
freeTierQuota: 'free-tier-quota'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'

View File

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

View File

@@ -0,0 +1,63 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
test.beforeEach(async ({ page }) => {
const features = {
free_tier_job_allowance_enabled: true,
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
}
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
})
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
const freeTierQuota = new FreeTierQuota(comfyPage)
await test.step('Populates initial state from config', async () => {
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
expect(await freeTierQuota.getMax()).toBe('5')
})
await test.step('available decrements on run', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('connects to detached run button', async () => {
const handle = comfyPage.actionbar.dragHandle
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
expect(await freeTierQuota.getAvailable()).toBe('2')
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
})
await test.step('Detects workflows with Partner nodes', async () => {
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await node.delete()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('Does not decrease past 0', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await execution.run()
await execution.run()
await execution.run()
await comfyPage.nextFrame()
expect(await freeTierQuota.getAvailable()).toBe(undefined)
})
})
})

View File

@@ -67,6 +67,22 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
await expect(dialog).toBeVisible()
})
test('Reopening the editor after save restores the drawn mask', async ({
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
const savedMask = await maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX)
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
await maskEditor.reopenDialog()
await expect
.poll(() => maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX))
.toBe(savedMask)
})
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)

View File

@@ -7,6 +7,45 @@ import {
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
test.describe('media selection', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
})
test('keeps a selected image loaded when it is selected again', async ({
comfyPage
}) => {
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
expect(loadImageNodes, 'Workflow has one Load Image node').toHaveLength(1)
const [loadImageNode] = loadImageNodes
const imageWidget = await loadImageNode.getWidgetByName('image')
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const imageLoadError = node.getByTestId(TestIds.errors.imageLoadError)
const selectedImageButton = node.getByRole('button', {
name: 'example.png',
exact: true
})
await expect(selectedImageButton).toBeVisible()
await expect(imageLoadError).toBeHidden()
await selectedImageButton.click()
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
await expect(menu).toBeVisible()
await menu.getByText('example.png', { exact: true }).click()
await expect(menu).toBeHidden()
await expect(selectedImageButton).toBeFocused()
await expect(selectedImageButton).toBeVisible()
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
await expect(imageLoadError).toBeHidden()
})
})
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')

View File

@@ -1,3 +1,9 @@
coverage:
status:
patch:
default:
informational: true
comment:
layout: 'header, diff, flags, files'
behavior: default

11
global.d.ts vendored
View File

@@ -41,16 +41,6 @@ interface GtagFunction {
(...args: unknown[]): void
}
interface DatadogRumDurationVitalOptions {
startTime: number
duration: number
context?: Record<string, unknown>
}
interface DatadogRumClient {
addDurationVital(name: string, options: DatadogRumDurationVitalOptions): void
}
type SyftDataTraits = Record<string, string | number | null | undefined>
interface SyftDataPendingFetch {
@@ -111,7 +101,6 @@ interface Window {
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
DD_RUM?: DatadogRumClient
syft?: SyftDataClient | SyftDisabledClient
syftc?: { sourceId?: string; enabled?: boolean }
ire_o?: string

View File

@@ -29,6 +29,8 @@
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"fallow": "fallow",
"fallow:audit": "fallow audit",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"json-schema": "tsx scripts/generate-json-schema.ts",
@@ -172,6 +174,7 @@
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fallow": "catalog:",
"fast-check": "catalog:",
"fs-extra": "^11.2.0",
"globals": "catalog:",

View File

@@ -40,6 +40,11 @@ export type ComfyDesktop2TelemetryProperties = Record<
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export type ComfyDesktop2FirebaseAuthState =
| { status: 'pending' }
| { status: 'signed_out' }
| { status: 'signed_in'; userId: string }
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
@@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge {
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
}
export interface ComfyDesktop2Bridge {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.2",
"version": "0.1.3",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",

View File

@@ -49,7 +49,7 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-credit: #ddbd31;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
@@ -140,26 +140,6 @@
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--subscription-button-gradient:
linear-gradient(
315deg,
rgb(105 230 255 / 0.15) 0%,
rgb(99 73 233 / 0.5) 100%
),
radial-gradient(
70.71% 70.71% at 50% 50%,
rgb(62 99 222 / 0.15) 0.01%,
rgb(66 0 123 / 0.5) 100%
),
linear-gradient(
92deg,
#d000ff 0.38%,
#b009fe 37.07%,
#3e1ffc 65.17%,
#009dff 103.86%
),
linear-gradient(var(--color-button-surface, #2d2e32));
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
--code-bg-color: rgb(96 165 250 / 0.2);

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
escapeVueI18nMessageSyntax,
formatLocalizedMediumDate,
formatLocalizedNumber,
getFilePathSeparatorVariants,
@@ -478,49 +477,4 @@ describe('formatUtil', () => {
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
})
})
describe('escapeVueI18nMessageSyntax', () => {
it('escapes a literal @ that would break linked-message compilation', () => {
expect(
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
})
it('escapes @ in an email address', () => {
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
"support{'@'}comfy.org"
)
})
it('escapes interpolation braces', () => {
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
"size {'{'}w{'}'}x{'{'}h{'}'}"
)
})
it('escapes the plural pipe separator', () => {
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
"foreground {'|'} background"
)
})
it('escapes the modulo percent so it cannot re-form %{', () => {
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
"50{'%'}{'{'}done{'}'}"
)
})
it('escapes every occurrence in a single pass', () => {
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
"{'@'}a {'@'}b {'@'}c"
)
})
it('leaves strings without syntax characters unchanged', () => {
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
'no special chars here'
)
expect(escapeVueI18nMessageSyntax('')).toBe('')
})
})
})

View File

@@ -178,40 +178,6 @@ export function normalizeI18nKey(key: string) {
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
}
/**
* Characters that vue-i18n's message compiler treats as syntax in message text,
* so plain text has to escape them to render verbatim through `t()`/`st()`:
*
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
* `Invalid linked format`.
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
* brace throws `Unterminated/Unbalanced closing brace`.
* - `|` separates plural branches, so `a | b` silently renders as one branch.
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
*
* The set is a build-inlined `const enum` (`TokenChars`) in
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
*
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
*/
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
/**
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
*
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
* escape output itself contains `{`/`}`, so it is not idempotent.
*/
export function escapeVueI18nMessageSyntax(text: string): string {
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}
/**
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
* @param input The dynamic prompt to process

88
pnpm-lock.yaml generated
View File

@@ -240,6 +240,9 @@ catalogs:
eslint-plugin-vue:
specifier: ^10.9.1
version: 10.9.1
fallow:
specifier: ^2.102.0
version: 2.102.0
fast-check:
specifier: ^4.5.3
version: 4.5.3
@@ -763,6 +766,9 @@ importers:
eslint-plugin-vue:
specifier: 'catalog:'
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
fallow:
specifier: 'catalog:'
version: 2.102.0
fast-check:
specifier: 'catalog:'
version: 4.5.3
@@ -1908,6 +1914,46 @@ packages:
'@exodus/crypto':
optional: true
'@fallow-cli/darwin-arm64@2.102.0':
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
cpu: [arm64]
os: [darwin]
'@fallow-cli/darwin-x64@2.102.0':
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
cpu: [x64]
os: [darwin]
'@fallow-cli/linux-arm64-gnu@2.102.0':
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-arm64-musl@2.102.0':
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-x64-gnu@2.102.0':
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
cpu: [x64]
os: [linux]
'@fallow-cli/linux-x64-musl@2.102.0':
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
cpu: [x64]
os: [linux]
'@fallow-cli/win32-arm64-msvc@2.102.0':
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
cpu: [arm64]
os: [win32]
'@fallow-cli/win32-x64-msvc@2.102.0':
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
cpu: [x64]
os: [win32]
'@firebase/analytics-compat@0.2.18':
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
peerDependencies:
@@ -5682,6 +5728,11 @@ packages:
extendable-media-recorder@9.2.27:
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
fallow@2.102.0:
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
engines: {node: '>=16'}
hasBin: true
fast-check@4.5.3:
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
engines: {node: '>=12.17.0'}
@@ -10044,6 +10095,30 @@ snapshots:
'@exodus/bytes@1.7.0': {}
'@fallow-cli/darwin-arm64@2.102.0':
optional: true
'@fallow-cli/darwin-x64@2.102.0':
optional: true
'@fallow-cli/linux-arm64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-arm64-musl@2.102.0':
optional: true
'@fallow-cli/linux-x64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-x64-musl@2.102.0':
optional: true
'@fallow-cli/win32-arm64-msvc@2.102.0':
optional: true
'@fallow-cli/win32-x64-msvc@2.102.0':
optional: true
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
dependencies:
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
@@ -14095,6 +14170,19 @@ snapshots:
subscribable-things: 2.1.53
tslib: 2.8.1
fallow@2.102.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
'@fallow-cli/darwin-arm64': 2.102.0
'@fallow-cli/darwin-x64': 2.102.0
'@fallow-cli/linux-arm64-gnu': 2.102.0
'@fallow-cli/linux-arm64-musl': 2.102.0
'@fallow-cli/linux-x64-gnu': 2.102.0
'@fallow-cli/linux-x64-musl': 2.102.0
'@fallow-cli/win32-arm64-msvc': 2.102.0
'@fallow-cli/win32-x64-msvc': 2.102.0
fast-check@4.5.3:
dependencies:
pure-rand: 7.0.1

View File

@@ -89,6 +89,7 @@ catalog:
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6

View File

@@ -3,11 +3,9 @@ import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import {
escapeVueI18nMessageSyntax,
normalizeI18nKey
} from '@/utils/formatUtil'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import type { WidgetLabels } from './nodeDefLocaleSerializer'
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json'
@@ -17,10 +15,6 @@ interface WidgetInfo {
label?: string
}
interface WidgetLabels {
[key: string]: Record<string, { name: string }>
}
test('collect-i18n-node-defs', async ({ comfyPage }) => {
// Mock view route
await comfyPage.page.route('**/view**', async (route) => {
@@ -47,26 +41,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
}
)
const allDataTypesLocale = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => {
const inputDataTypes = Object.values(nodeDef.inputs).map(
(inputSpec) => inputSpec.type
)
const outputDataTypes = nodeDef.outputs.map(
(outputSpec) => outputSpec.type
)
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
(type: string) => type.split(',')
)
return allDataTypes.map((dataType) => [
normalizeI18nKey(dataType),
escapeVueI18nMessageSyntax(dataType)
])
})
.sort((a, b) => a[0].localeCompare(b[0]))
)
async function extractWidgetLabels() {
const nodeLabels: WidgetLabels = {}
@@ -95,14 +69,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
[nodeDef.name, nodeDef.display_name, inputNames]
)
// Format runtime widgets
const runtimeWidgets = Object.fromEntries(
Object.entries(widgetsMappings)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, value]) => [
normalizeI18nKey(key),
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
])
.map(([key, name]) => [key, { name }])
)
if (Object.keys(runtimeWidgets).length > 0) {
@@ -121,97 +91,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
}
const nodeDefLabels = await extractWidgetLabels()
function extractInputs(nodeDef: ComfyNodeDefImpl) {
const inputs = Object.fromEntries(
Object.values(nodeDef.inputs).flatMap((input) => {
const name =
input.name === undefined
? undefined
: escapeVueI18nMessageSyntax(input.name)
const tooltip = input.tooltip
if (name === undefined && tooltip === undefined) {
return []
}
return [
[
normalizeI18nKey(input.name),
{
name,
tooltip
}
]
]
})
)
return Object.keys(inputs).length > 0 ? inputs : undefined
}
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
const outputs = Object.fromEntries(
nodeDef.outputs.flatMap((output, i) => {
// Ignore data types if they are already translated in allDataTypesLocale.
const name =
output.name === undefined || output.name in allDataTypesLocale
? undefined
: escapeVueI18nMessageSyntax(output.name)
const tooltip = output.tooltip
if (name === undefined && tooltip === undefined) {
return []
}
return [
[
i.toString(),
{
name,
tooltip
}
]
]
})
)
return Object.keys(outputs).length > 0 ? outputs : undefined
}
const allNodeDefsLocale = Object.fromEntries(
nodeDefs
.sort((a, b) => a.name.localeCompare(b.name))
.map((nodeDef) => {
const inputs = {
...extractInputs(nodeDef),
...(nodeDefLabels[nodeDef.name] ?? {})
}
return [
normalizeI18nKey(nodeDef.name),
{
display_name: escapeVueI18nMessageSyntax(
nodeDef.display_name ?? nodeDef.name
),
description: nodeDef.description
? escapeVueI18nMessageSyntax(nodeDef.description)
: undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: extractOutputs(nodeDef)
}
]
})
)
const allNodeCategoriesLocale = Object.fromEntries(
nodeDefs.flatMap((nodeDef) =>
nodeDef.category
.split('/')
.map((category) => [
normalizeI18nKey(category),
escapeVueI18nMessageSyntax(category)
])
)
)
const { dataTypes, nodeCategories, nodeDefinitions } =
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
fs.writeFileSync(
@@ -219,13 +100,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
JSON.stringify(
{
...locale,
dataTypes: allDataTypesLocale,
nodeCategories: allNodeCategoriesLocale
dataTypes,
nodeCategories
},
null,
2
)
)
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
})

View File

@@ -0,0 +1,130 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
function render(message: string): string {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { value: message } }
})
return i18n.global.t('value')
}
describe('serializeNodeDefLocales', () => {
it('escapes compiled fields and preserves raw tooltips', () => {
const syntax = '@ $ {value} | 50%{done}'
const inputName = `Input ${syntax}`
const outputName = `Output ${syntax}`
const dataType = `TYPE ${syntax}`
const category = `Category ${syntax}`
const nodeDef = {
name: 'Test.Node',
display_name: `Display ${syntax}`,
description: `Description ${syntax}`,
category,
inputs: {
input: {
name: inputName,
type: dataType,
tooltip: `Input tooltip ${syntax}`
}
},
outputs: [
{
name: outputName,
type: 'OTHER',
tooltip: `Output tooltip ${syntax}`
}
]
}
const { dataTypes, nodeCategories, nodeDefinitions } =
serializeNodeDefLocales([nodeDef], {
'Test.Node': {
'Runtime.Widget': { name: `Widget ${syntax}` }
}
})
const serializedNode = nodeDefinitions.Test_Node
const serializedInput =
serializedNode.inputs['Input @ $ {value} | 50%{done}']
const serializedOutput = serializedNode.outputs['0']
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
expect(render(serializedNode.description)).toBe(nodeDef.description)
expect(render(serializedInput.name)).toBe(inputName)
expect(render(serializedOutput.name)).toBe(outputName)
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
`Widget ${syntax}`
)
expect(render(dataTypes[dataType])).toBe(dataType)
expect(render(nodeCategories[category])).toBe(category)
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
})
it('preserves locale shapes and ordering', () => {
const { dataTypes, nodeCategories, nodeDefinitions } =
serializeNodeDefLocales(
[
{
name: 'Z.Node',
description: '',
category: 'group/sub.group',
inputs: {
omitted: { type: 'Z.TYPE' },
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
},
outputs: [
{ name: 'A_TYPE', type: 'A_TYPE' },
{ name: 'Custom.Output', type: 'Z.TYPE' },
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
]
},
{
name: 'A.Node',
category: 'group',
inputs: {},
outputs: []
}
],
{
'Z.Node': {
'Runtime.Widget': { name: 'Runtime.Label' }
}
}
)
expect(dataTypes).toEqual({
A_TYPE: 'A_TYPE',
Z_TYPE: 'Z.TYPE'
})
expect(nodeCategories).toEqual({
group: 'group',
sub_group: 'sub.group'
})
expect(nodeDefinitions).toEqual({
A_Node: {
display_name: 'A.Node',
description: undefined,
inputs: undefined,
outputs: undefined
},
Z_Node: {
display_name: 'Z.Node',
description: undefined,
inputs: {
'': { name: undefined, tooltip: 'raw @ tooltip' },
Runtime_Widget: { name: 'Runtime.Label' }
},
outputs: {
1: { name: 'Custom.Output', tooltip: undefined },
2: { name: undefined, tooltip: 'raw output @ tooltip' }
}
}
})
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
})
})

View File

@@ -0,0 +1,127 @@
import { normalizeI18nKey } from '@/utils/formatUtil'
interface LocalizableInput {
type: string
name?: string
tooltip?: string
}
interface LocalizableOutput {
type: string
name?: string
tooltip?: string
}
interface LocalizableNodeDef {
category: string
inputs: Record<string, LocalizableInput>
name: string
outputs: LocalizableOutput[]
description?: string
display_name?: string
}
export type WidgetLabels = Record<
string,
Record<string, { name: string | undefined }>
>
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
function escapeMessage(text: string): string {
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}
export function serializeNodeDefLocales(
nodeDefs: readonly LocalizableNodeDef[],
widgetLabels: WidgetLabels = {}
) {
const dataTypes = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => [
...Object.values(nodeDef.inputs).map(({ type }) => type),
...nodeDef.outputs.map(({ type }) => type)
])
.flatMap((type) => type.split(','))
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
.sort((a, b) => a[0].localeCompare(b[0]))
)
function serializeInputs(nodeDef: LocalizableNodeDef) {
const inputs = Object.fromEntries(
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
if (name === undefined && tooltip === undefined) return []
return [
[
normalizeI18nKey(name ?? ''),
{
name: name === undefined ? undefined : escapeMessage(name),
tooltip
}
]
]
})
)
return Object.keys(inputs).length > 0 ? inputs : undefined
}
function serializeOutputs(nodeDef: LocalizableNodeDef) {
const outputs = Object.fromEntries(
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
const serializedName =
name === undefined || name in dataTypes
? undefined
: escapeMessage(name)
if (serializedName === undefined && tooltip === undefined) return []
return [[index.toString(), { name: serializedName, tooltip }]]
})
)
return Object.keys(outputs).length > 0 ? outputs : undefined
}
function serializeWidgetLabels(nodeName: string) {
return Object.fromEntries(
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
normalizeI18nKey(name),
{
name: label.name === undefined ? undefined : escapeMessage(label.name)
}
])
)
}
const nodeDefinitions = Object.fromEntries(
[...nodeDefs]
.sort((a, b) => a.name.localeCompare(b.name))
.map((nodeDef) => {
const inputs = {
...serializeInputs(nodeDef),
...serializeWidgetLabels(nodeDef.name)
}
return [
normalizeI18nKey(nodeDef.name),
{
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
description: nodeDef.description
? escapeMessage(nodeDef.description)
: undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: serializeOutputs(nodeDef)
}
]
})
)
const nodeCategories = Object.fromEntries(
nodeDefs.flatMap(({ category }) =>
category
.split('/')
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
)
)
return { dataTypes, nodeCategories, nodeDefinitions }
}

View File

@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).toContain('px-2')
expect(actionbarContainer!.classList).not.toContain('w-0')
} finally {
unmount()
vi.unstubAllGlobals()

View File

@@ -11,7 +11,7 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div class="flex items-start gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
@@ -34,61 +34,75 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
>
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container relative flex items-center gap-2',
isActionbarContainerEmpty &&
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
/>
</div>
</div>
<FreeTierQuota v-if="!isActionbarFloating" />
</div>
</div>
<ErrorOverlay />
@@ -147,6 +161,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -209,21 +224,6 @@ const hasDockedButtons = computed(() => {
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
return cn(base, 'px-2', 'border-interface-stroke')
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)

View File

@@ -75,6 +75,7 @@
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
<FreeTierQuota v-if="!isDocked" />
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
@@ -109,6 +110,7 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
const mockCanRunWorkflows = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
canRunWorkflows: mockCanRunWorkflows
})
}))
@@ -32,7 +32,7 @@ function renderWrapper() {
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
:key="canRunWorkflows ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
const { isActiveSubscription } = useBillingContext()
const { canRunWorkflows } = useBillingContext()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--component] text-sm text-credit" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
@@ -48,7 +48,7 @@
</Button>
<Button
v-if="isCloud && isFreeTier"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
@@ -72,7 +72,7 @@
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
button-variant="gradient"
button-variant="subscribe"
@subscribed="handleSubscribed"
/>
</div>

View File

@@ -2,7 +2,7 @@
<Button
v-if="isCloud && isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
variant="subscribe"
size="sm"
data-testid="topbar-subscribe-button"
@click="handleClick"

View File

@@ -24,8 +24,8 @@ export const buttonVariants = cva({
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
subscribe:
'border-transparent bg-credit text-charcoal-800 hover:opacity-80'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -58,7 +58,7 @@ const variants = [
'base',
'tertiary',
'overlay-white',
'gradient'
'subscribe'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -132,7 +132,7 @@ export function useChart(
const chartInstance = ref<Chart | null>(null)
function createChart() {
if (!canvasRef.value) return
if (canvasRef.value === null) return
chartInstance.value?.destroy()

View File

@@ -112,5 +112,13 @@ export interface BillingContext extends BillingState, BillingActions {
* (legacy) per-member tier plan, which keeps the old team pricing table.
*/
isLegacyTeamPlan: ComputedRef<boolean>
/**
* True when the subscription is a team plan of either generation. Unlike
* `isLegacyTeamPlan` this does not require an active subscription: the spend
* gate folds billing_status into is_active, so a paused or payment-failed team
* plan reports is_active=false and must still read as a team plan.
*/
isTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
canRunWorkflows: ComputedRef<boolean>
}

View File

@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockBillingControlEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -27,7 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockBillingControlEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -59,13 +59,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
Object.defineProperty(mockBillingControlEnabled, 'value', {
get: () => billingControlEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
billingControlEnabledRef.value = value
}
})
return {
@@ -74,8 +72,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
get billingControlEnabled() {
return mockBillingControlEnabled.value
}
}
})
@@ -165,7 +163,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -177,27 +175,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
it('selects workspace type for personal when billing control is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team regardless of consolidated billing', () => {
it('selects workspace type for team regardless of billing control', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -298,7 +296,7 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -307,16 +305,16 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
@@ -325,9 +323,9 @@ describe('useBillingContext', () => {
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -555,4 +553,110 @@ describe('useBillingContext', () => {
expect(isLegacyTeamPlan.value).toBe(true)
})
})
describe('isTeamPlan', () => {
it('is false for a personal workspace', () => {
const { isTeamPlan } = useBillingContext()
expect(isTeamPlan.value).toBe(false)
})
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
// isTeamPlan reads the credit stop and the slug, never the tier — which is
// what keeps it working despite that divergence.
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
// The banner states that need isTeamPlan most — paused and payment_failed —
// are exactly the ones the backend reports with is_active=false, because the
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
// subscription would blank the banner precisely when it is needed.
it('stays true for a paused team plan, which the backend reports inactive', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'paused',
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('stays true for a legacy team plan whose payment failed', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'payment_failed',
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is false for a team workspace on a personal-tier plan', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
plan_slug: 'pro-monthly'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(false)
})
})
})

View File

@@ -6,6 +6,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
@@ -35,8 +36,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
* workspaces, and for personal workspaces once billing control is enabled;
* personal workspaces otherwise stay on legacy billing
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -129,6 +130,16 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const freeTierQuota = useFreeTierQuota()
const canRunWorkflows = computed(
() =>
isActiveSubscription.value &&
(!isFreeTier.value ||
!freeTierQuota.quotaEnabled.value ||
freeTierQuota.freeTierExecutionPermitted.value)
)
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
@@ -141,6 +152,21 @@ function useBillingContextInternal(): BillingContext {
false)
)
// Plan identity, independent of subscription health: the per-credit Team plan
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
// isActiveSubscription on purpose — paused and payment_failed both force
// is_active=false, which is exactly when callers still need to know this is a
// team plan.
const isTeamPlan = computed(
() =>
type.value === 'workspace' &&
(currentTeamCreditStop.value !== null ||
(currentPlanSlug.value
?.toLowerCase()
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
false))
)
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
@@ -191,9 +217,9 @@ function useBillingContextInternal(): BillingContext {
error.value = null
}
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
// type flips when the team-workspaces or billing-control flag resolves from
// authenticated config, swapping the active backend. Reset then reinit on
// every workspace-id or type change.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
@@ -297,8 +323,10 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
canRunWorkflows,
isFreeTier,
isLegacyTeamPlan,
isTeamPlan,
billingStatus,
subscriptionStatus,
tier,

View File

@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
billingControlEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
})
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
it('moves personal to workspace billing when billing control is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
it('uses workspace billing for team workspaces regardless of billing control', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
it('uses workspace billing for team workspaces with billing control enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()

View File

@@ -8,7 +8,7 @@ import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* stay legacy until `billingControlEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
@@ -23,7 +23,7 @@ export function useBillingRouting() {
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
return 'legacy'
}

View File

@@ -0,0 +1,193 @@
import { fromAny } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { useMaskEditorLoader } from './useMaskEditorLoader'
// ---- Module Mocks ----
const mockDataStore: Record<string, unknown> = {
inputData: null,
sourceNode: null,
setLoading: vi.fn()
}
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: vi.fn(() => mockDataStore)
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: vi.fn(() => ({
getNodeOutputs: vi.fn(() => undefined)
}))
}))
const distribution = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
}
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `http://localhost:8188/api${route}`)
}
}))
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => ''),
getRandParam: vi.fn(() => '')
}
}))
// Mock Image constructor so the loader's image fetches resolve without a
// network. Records every requested URL; URLs matching failUrlPattern reject
// like a 404 would (reset per test in beforeEach).
const requestedUrls: string[] = []
let failUrlPattern: RegExp | null = null
class MockImage {
crossOrigin = ''
onload: ((ev: Event) => void) | null = null
onerror: ((ev: unknown) => void) | null = null
private _src = ''
get src() {
return this._src
}
set src(value: string) {
this._src = value
requestedUrls.push(value)
queueMicrotask(() => {
if (failUrlPattern?.test(value)) this.onerror?.(new Event('error'))
else this.onload?.(new Event('load'))
})
}
}
function createLoadImageNode(widgetValue: string): LGraphNode {
return fromAny<LGraphNode, unknown>({
id: 7,
type: 'LoadImage',
imgs: [{ src: 'http://localhost:8188/api/view?filename=whatever.png' }],
images: undefined,
widgets: [{ name: 'image', value: widgetValue }]
})
}
function subfolderOf(url: string): string | null {
return new URL(url).searchParams.get('subfolder')
}
function requestedLayerUrls(layerFilename: string): string[] {
return requestedUrls.filter((url) => url.includes(layerFilename))
}
describe('useMaskEditorLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
requestedUrls.length = 0
failUrlPattern = null
distribution.isCloud = false
mockDataStore.inputData = null
mockDataStore.sourceNode = null
vi.stubGlobal('Image', MockImage)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('loads layer files from the input root for saves without a subfolder prefix', async () => {
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of [
'clipspace-mask-123.png',
'clipspace-paint-123.png'
]) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBeNull()
}
}
expect(mockDataStore.inputData).toMatchObject({
sourceRef: { filename: 'clipspace-mask-123.png', type: 'input' },
nodeId: 7
})
})
it('loads layer files from the clipspace subfolder for legacy saves', async () => {
const node = createLoadImageNode(
'clipspace/clipspace-painted-masked-123.png [input]'
)
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of [
'clipspace-mask-123.png',
'clipspace-paint-123.png'
]) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBe('clipspace')
}
}
})
it('keeps the clipspace subfolder for cloud-resolved layer files', async () => {
distribution.isCloud = true
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
painted_masked: 'hash-painted-masked.png',
painted: 'hash-painted.png',
paint: 'hash-paint.png',
mask: 'hash-mask.png'
})
} as Response)
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
for (const layerFilename of ['hash-painted-masked.png', 'hash-paint.png']) {
const layerUrls = requestedLayerUrls(layerFilename)
expect(layerUrls.length).toBeGreaterThan(0)
for (const url of layerUrls) {
expect(subfolderOf(url)).toBe('clipspace')
}
}
})
it('falls back to the node image when layer files are missing', async () => {
failUrlPattern = /clipspace-(mask|paint)-123\.png/
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
await useMaskEditorLoader().loadFromNode(node)
expect(mockDataStore.inputData).toMatchObject({
sourceRef: { filename: 'clipspace-painted-masked-123.png' },
paintLayer: undefined,
nodeId: 7
})
})
it('still fails when the node image itself cannot be loaded', async () => {
failUrlPattern = /./
const node = createLoadImageNode('some-regular-image.png [input]')
await expect(useMaskEditorLoader().loadFromNode(node)).rejects.toThrow()
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
false,
expect.any(String)
)
})
})

View File

@@ -53,10 +53,10 @@ function imageLayerFilenamesIfApplicable(
}
}
function toRef(filename: string): ImageRef {
function toRef(filename: string, subfolder: string): ImageRef {
return {
filename,
subfolder: 'clipspace',
subfolder,
type: 'input'
}
}
@@ -150,34 +150,72 @@ export function useMaskEditorLoader() {
}
}
const baseImageUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
: nodeImageUrl
// Pattern-matched layer files live next to the painted-masked image the
// widget points at: the input root for saves under the unified upload
// contract (#12318), or the clipspace subfolder for saves made before
// it. Cloud resolves the hash filenames from its mask-layers API
// server-side, so its refs keep the subfolder they always used.
const layerSubfolder = maskLayersFromApi
? 'clipspace'
: (nodeImageRef.subfolder ?? '')
const sourceRef = imageLayerFilenames?.maskedImage
? parseImageRef(baseImageUrl)
: nodeImageRef
const layeredBaseUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({
ref: toRef(imageLayerFilenames.maskedImage, layerSubfolder)
})
: undefined
const baseImageUrl = layeredBaseUrl ?? nodeImageUrl
let paintLayerUrl: string | null = null
if (maskLayersFromApi?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
paintLayerUrl = mkFileUrl({
ref: toRef(maskLayersFromApi.paint, layerSubfolder)
})
} else if (imageLayerFilenames?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
paintLayerUrl = mkFileUrl({
ref: toRef(imageLayerFilenames.paint, layerSubfolder)
})
}
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
loadImageLayer(baseImageUrl, 'rgb'),
loadImageLayer(baseImageUrl, 'a'),
paintLayerUrl
? loadPaintLayer(paintLayerUrl)
: Promise.resolve(undefined)
])
const loadLayers = (baseUrl: string, paintUrl: string | null) =>
Promise.all([
loadImageLayer(baseUrl, 'rgb'),
loadImageLayer(baseUrl, 'a'),
paintUrl ? loadPaintLayer(paintUrl) : Promise.resolve(undefined)
])
const loadInputData = async () => {
try {
const [baseLayer, maskLayer, paintLayer] = await loadLayers(
baseImageUrl,
paintLayerUrl
)
return {
baseLayer,
maskLayer,
paintLayer,
sourceRef: layeredBaseUrl
? parseImageRef(baseImageUrl)
: nodeImageRef
}
} catch (error) {
if (!layeredBaseUrl) throw error
console.warn(
'[MaskEditorLoader] Failed to load editor layer files, falling back to the node image',
error
)
const [baseLayer, maskLayer] = await loadLayers(nodeImageUrl, null)
return {
baseLayer,
maskLayer,
paintLayer: undefined,
sourceRef: nodeImageRef
}
}
}
dataStore.inputData = {
baseLayer,
maskLayer,
paintLayer,
sourceRef,
...(await loadInputData()),
nodeId: node.id
}

View File

@@ -1,12 +1,19 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, toValue } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: SubgraphInput
@@ -150,3 +157,20 @@ export const usePriceBadge = () => {
updateSubgraphCredits
}
}
export const useCreditsBadgesInGraph = createSharedComposable(() => {
const { isCreditsBadge } = usePriceBadge()
const vueNodeLifecycle = useVueNodeLifecycle()
return computed(() => {
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
if (!app.graph) return []
return mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
})
})

View File

@@ -7,7 +7,7 @@ import {
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
localStorage.setItem('ff:billing_control_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
it('billingControlEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
})
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
cachedBillingControlEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
billing_control_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
@@ -295,15 +295,14 @@ describe('useFeatureFlags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
})

View File

@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -32,7 +32,8 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -191,15 +192,25 @@ export function useFeatureFlags() {
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
* Whether personal workspaces use the workspace-scoped billing flow. While
* false (default), personal workspaces stay on the legacy per-user billing
* flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
get billingControlEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
remoteConfig.value.billing_control_enabled,
cachedBillingControlEnabled
)
},
get freeTierJobAllowanceEnabled() {
const config = remoteConfig.value as typeof remoteConfig.value & {
free_tier_job_allowance_enabled?: boolean
}
return resolveFlag(
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
config.free_tier_job_allowance_enabled,
false
)
},
get signupTurnstileMode() {

View File

@@ -2473,6 +2473,11 @@
},
"credits": {
"activity": "Activity",
"insufficient": {
"memberTitle": "This workspace is out of credits",
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
"memberCta": "Ok, got it"
},
"credits": "Credits",
"yourCreditBalance": "Your credit balance",
"purchaseCredits": "Purchase Credits",
@@ -2882,6 +2887,32 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"billingStatus": {
"warning": {
"title": "Payment declined",
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
},
"paused": {
"title": "Subscription paused",
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
"outOfCredits": {
"title": "Out of credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
"addCredits": "Add credits",
"dismiss": "Dismiss"
},
"ending": {
"title": "Your team plan ends on {date}",
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan"
},
"updatePayment": "Update payment"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
@@ -3556,6 +3587,9 @@
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback",
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
"freeTierRunsExhausted": "No runs left",
"freeTierPartner": "Partner nodes need a paid plan",
"share": "Share",
"shareTooltip": "Share workflow"
},

View File

@@ -1,35 +0,0 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
/**
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
* sync). `collect-i18n-node-defs.ts` escapes such values with
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
* output actually compiles and renders the original literal text.
*/
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
const compile = (message: string) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { value: message } }
})
return i18n.global.t('value')
}
it.for([
'clips (tagged @Audio1-3 in the prompt)',
'support@comfy.org',
'resolution {width}x{height}',
'foreground | background',
'50%{done}',
'all of @ { } | % together',
'no special chars here'
])('renders %s as the original literal text', (raw) => {
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
})
})

View File

@@ -19,6 +19,7 @@ import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
@@ -141,6 +142,10 @@ app
modules: [VueFireAuth()]
})
if (isCloud && hasHostTelemetryBridge) {
syncHostUserIdWithFirebaseAuth()
}
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,

View File

@@ -147,7 +147,7 @@
<div v-if="showActionButton" class="flex flex-col gap-3">
<Button
v-if="isFreeTier"
variant="gradient"
variant="subscribe"
size="lg"
class="w-full font-normal"
@click="handleUpgradeToAddCredits"

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
const DOT_COLORS = [
'bg-destructive-background',
'bg-warning-background',
'bg-success-background'
]
const { showSubscriptionDialog } = useBillingContext()
const { t } = useI18n()
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
useFreeTierQuota()
const dotColor = computed(() => {
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
return DOT_COLORS[
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
]
})
const label = computed(() =>
available.value === 0
? t('actionbar.freeTierRunsExhausted')
: t('actionbar.freeTierRuns', {
available: available.value,
MAX_AVAILABLE: maxAvailable.value
})
)
</script>
<template>
<div
v-if="quotaEnabled"
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
data-testid="free-tier-quota"
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
>
<div
v-if="hasInvalidNodes"
class="flex w-full items-center justify-center gap-2"
>
<i class="icon-[comfy--credits] bg-credit" />
{{ t('actionbar.freeTierPartner') }}
</div>
<div v-else class="flex w-full items-center justify-between">
<div class="flex gap-2" :aria-label="label" role="img">
<div
v-for="index in maxAvailable"
:key="index"
:class="
cn(
'size-1.5 rounded-full',
index > available ? 'bg-secondary-background-selected' : dotColor
)
"
/>
</div>
<div v-text="label" />
</div>
</div>
</template>

View File

@@ -123,7 +123,7 @@
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
aria-hidden="true"
/>
<span

View File

@@ -2,7 +2,7 @@
<Button
:size
:disabled="disabled"
:variant="buttonVariant === 'gradient' ? 'gradient' : 'primary'"
:variant="buttonVariant === 'subscribe' ? 'subscribe' : 'primary'"
:class="cn('font-bold', fluid && 'w-full')"
@click="handleSubscribe"
>
@@ -28,7 +28,7 @@ const {
} = defineProps<{
label?: string
size?: 'sm' | 'lg'
buttonVariant?: 'default' | 'gradient'
buttonVariant?: 'default' | 'subscribe'
fluid?: boolean
disabled?: boolean
}>()

View File

@@ -5,7 +5,7 @@
showDelay: 600
}"
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
variant="gradient"
variant="subscribe"
size="unset"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"

View File

@@ -8,10 +8,19 @@ const mockDialogService = {
showTopUpCreditsDialog: vi.fn()
}
const mockBilling = {
fetchStatus: vi.fn(),
fetchBalance: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => mockBilling)
}))
describe('useAccountPreconditionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -55,4 +64,18 @@ describe('useAccountPreconditionDialog', () => {
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
})
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
useAccountPreconditionDialog().open('credits')
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
})
it('does not touch billing state for non-credit preconditions', () => {
useAccountPreconditionDialog().open('subscription')
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
})
})

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