Compare commits

..

33 Commits

Author SHA1 Message Date
Michael B
e88d00ff3e fix(website): correct donwload typo in MCP FAQ answer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:12:21 -04:00
Michael B
ba77743cc7 fix(website): widen FAQ URL autolink pattern
Include plus, comma, and semicolon in the FAQ answer URL character class
so URLs with query or matrix parameters are captured whole. Trailing
sentence punctuation is still stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:08:05 -04:00
Michael B
9f2ab18759 feat(website): rewrite MCP FAQ, link URLs, and add Step 2 server URL
Rewrite the MCP FAQ to nine Q&As (adds cost and local-ComfyUI entries,
drops the sign-in troubleshooting item) in both locales, and auto-link
http(s) URLs in FAQ answers instead of rendering them as plain text.

Give Setup Step 2 a copyable Comfy Cloud MCP server URL alongside the
docs button by splitting the feature card's single action into
independent copyable and link slots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:42:29 -04:00
Michael B
094933b0e2 Copy changes 2026-07-15 16:21:05 -04:00
Michael B
8200269828 fix(website): size MCP card media to 620x364 with per-card fit
Constrain each MCP tool card's media to a uniform 620x364 frame
(matching Figma) and add an opt-in cover/contain fit. Images use
cover so full-bleed assets fill the rounded frame; videos use
contain. Adds a fit prop to VideoPlayer without changing its
site-wide cover default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:07:30 -04:00
Michael B
94e2d80d50 fix(website): use ui-overview clip for MCP App card
Swap the App card video to the App-mode showcase clip (ui-overview.webm).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:43:32 -04:00
Michael B
e5ce2845f2 fix(website): use showcase video for MCP App card
Point the App card back at the home-page showcase clip
(video-showcase.webm) instead of the static image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:41:32 -04:00
Michael B
9c7335b85d feat(website): wire final MCP card imagery, App card as image
Point the App card at the produced ship-it-app-mode.png (replacing the
home-page showcase video). Direct any model and Generate in batches now
resolve to their final assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:38:38 -04:00
Michael B
d40a718df6 fix(website): reorder MCP tool cards to original-then-new
Show the three original cards (Generate anything, Search the ecosystem,
Run real workflows) first, followed by the three new ones (Direct any
model, Generate in batches, Ship it as an app).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:28:13 -04:00
Michael B
e724c92602 fix(website): use home-page showcase video for MCP App card
Replace the "Ship it as an app" placeholder image with the existing
home-page ProductShowcase clip (video-showcase.webm), matching the
autoplay/loop/no-controls pattern used by the Run real workflows card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:52:15 -04:00
Michael B
6d8afe413a feat(website): expand Comfy MCP capabilities section to six cards
Add Direct any model, Generate in batches, and Ship it as an app cards so
the differentiated MCP capabilities get their own real estate, and extend
Run real workflows to cover saved/shared workflows and the canvas handoff.

New cards use placeholder image URLs pending final imagery.

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

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

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

## Changes

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

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

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

- **Breaking**: none.

## Review Focus

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

## Credit

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

Fixes #13545
Fixes #13575

---------

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

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

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

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

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

## Changes

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

## Review Focus

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

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

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

## Screenshots (if applicable)

Not applicable.

---------

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

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

## Why this is needed

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

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

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

## The rule (design)

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

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

### In scope — examples

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

### Out of scope — examples

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

## Changes

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

## Review Focus

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

## Screenshots

### Before 


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

### After



https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
2026-07-14 14:39:20 +00:00
Benjamin Lu
16fdcab4be feat(telemetry): track signup/login flow errors in PostHog (#13504)
## Summary

Auth error toasts in the signup/login flow (e.g. "No account found with
this email…") were never tracked, leaving no analytics visibility into
signup dropoff. This adds a PostHog event for them.

## Changes

- **What**: New `app:user_auth_failed` PostHog event with `{ error_code,
auth_action }`, fired from a `reportAuthFlowError` wrapper in
`useAuthActions` before the existing error toast. Wired to the seven
auth-flow actions (`email_sign_in`, `email_sign_up`, `google_sign_in`,
`google_sign_up`, `github_sign_in`, `github_sign_up`, `password_reset`);
covers both the cloud login/signup pages and the in-app sign-in dialog
since all funnel through the same handler. Non-auth actions (logout,
billing) intentionally stay untracked.

## Review Focus

- PostHog provider only — not added to Mixpanel/GTM/CustomerIo/etc.
- No PII: only the Firebase error code is sent (`'unknown'` for
non-Firebase errors), never the message or email.
- The `useAuthActions` test mock of `wrapWithErrorHandlingAsync`
previously rethrew errors; the real implementation swallows them after
handling. The mock now matches the real contract, and one pre-existing
logout assertion that depended on the rethrow was updated.

Fixes
[MAR-451](https://linear.app/comfyorg/issue/MAR-451/add-tracking-for-signup-flow-errors)

---------

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-07-13 23:04:58 +00:00
Mobeen Abdullah
96c6dbcb69 fix(website): replace em dash meta title separators with hyphens (#13628)
## Summary

Switch the meta title separator sitewide from an em dash (—) to a spaced
hyphen ( - ), the follow-up to #13480 tracked as FE-1233, based on the
Screaming Frog crawl that flagged the em dash titles.

## Changes

- **What**: Replaced the em dash separator with a hyphen in every page
meta title, in both `en` and `zh-CN`. Covers ~44 inline page titles, the
shared title keys in `i18n/translations.ts` (`cloudNodes.meta.title`,
`mcp.meta.title`, `affiliate-terms.page.title`,
`enterprise-msa.page.title`, `affiliate.page.title`,
`launches.page.title`), and the dynamic `customers`, `demos`, and
`supported-models` `... - Comfy` suffixes. `BaseLayout` reuses each page
title for `og:title`, `twitter:title`, and the JSON-LD `name`, so those
inherit the change from a single source per page.
- Updated the e2e title assertions to match. `launches` asserts via
`t()` so it follows the translation change automatically.

## Review Focus

- **Scope is titles only.** The crawl and this ticket are about meta
titles. Description / body copy em dashes are left untouched, they are
marketing copy, which we keep em-dashed per the comms-only styling
decision. A full JSON-LD audit across all 57 routes confirms every
`name`/title field is now em-dash-free.
- **zh-CN cloud-nodes title keeps its Chinese double dash `——`**
(`自定义节点包合集——开箱即用`). That is correct CJK punctuation, a dash break, not a
brand/Latin separator, so the Latin-script separator concern does not
apply. Its e2e assertion is left as `——` to match.
- No visual change: titles are `<head>` metadata, not rendered UI, so
there is nothing to screenshot on the page. Rendered `<title>` output
was verified on the dev server across inline, `t()`-based, zh-CN,
dynamic, and 404 routes, and `og:`/`twitter:` inheritance was confirmed.

Ref: FE-1233
2026-07-13 22:24:52 +00:00
pythongosssss
ffb4ae34c6 feat: App mode UI updates (#13170)
## Summary

App mode now uses the same sidenav as the graph view instead of its own
custom navigation, and switching between modes happens through a new
animated graph/app toggle shared by both views.

## Changes

- **What**:
- LinearView hosts the standard `SideToolbar`, filtered to the Assets
and Apps tabs via new `visibleTabIds`/`forceConnected` props. The
bottom-panel and shortcut toggles hide in app mode, and
GraphCanvas/SubgraphBreadcrumb hide their instances so only one toolbar
and toggle render at a time.
- The workflow actions dropdown trigger is now a two-segment graph/app
toggle: the inactive segment switches modes directly, the active one
opens the actions menu as before, with a FLIP animation on switch.
- Each view renders its own toggle, so switching unmounts one and mounts
the other. A `displayLinearMode` ref in canvasStore trails `linearMode`
by two frames so the incoming toggle animates from the old mode instead
of popping in already-switched.
- AppModeToolbar slims down to the toggle plus a "Build an app" button;
the per-icon Assets/Apps/Share buttons are covered by the sidenav.
- The Apps tab gains "Create" header and empty-state actions wired to
`Comfy.NewBlankWorkflow`.
- App-mode feedback moves into `SidebarHelpCenterIcon` as a Typeform
popover with a load-error fallback, replacing `TypeformPopoverButton`
and `LinearFeedback` and simplifying LinearPreview to a single
`OutputHistory`.
- Tests for the toggle, sidebar tabs, help-center fallback, preview, and
view; screenshots regenerated.

## Review Focus

- The two-frame `displayLinearMode` lag — timing-sensitive; rapid
toggling cancels the pending frame chain so the wrong mode never
flashes.
- `SideToolbar` now serves both views: confirm only one instance renders
and the new props behave.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/9ad1eb4f-9370-4975-b3f1-b271482f2bf7

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:52:34 +00:00
Matt Miller
59341a4b71 feat: data-driven provider input types with JSON file upload for BYOK secrets (#13571)
## ELI-5

Some cloud providers hand you an API key you type in a box; others (like
Google Vertex) hand you a whole JSON file. The provider list already
comes from the server. This teaches each provider to also say *how* its
credential is entered. When a provider says `json_file`, the "Add
Secret" form now shows an **Upload JSON file** button plus a paste box
instead of the single-line password field. Everything else works exactly
as before.

## Summary

Extends the server-driven secret provider schema with optional
`input_type` (`'text' | 'json_file'`) and `label`, and renders a
file-upload + paste textarea for `json_file` providers (e.g. a Vertex
service-account JSON) instead of the password field.

## Changes

- **What**:
- `SecretProvider` ingest schema gains optional `input_type` and `label`
(added to the generated `types.gen.ts` / `zod.gen.ts` — see Review
Focus).
- `listSecretProviders()` now returns the full provider objects (id +
optional `input_type`/`label`) instead of just ids; `availableProviders`
carries them through to the form.
- The credential field switches on the selected provider's `input_type`:
`json_file` → upload button (reads the file into the value) + paste
textarea with light "must be valid JSON" validation; anything else → the
existing password field.
- Provider display label prefers the server `label`, falling back to the
frontend registry label, then the raw id.
  - New i18n keys under `secrets.*`.
- **Breaking**: none. Providers that omit `input_type` render exactly as
today (single-line secret). Read/List responses are untouched — the
credential value is still never echoed back.

## Review Focus

- **Judgment call — one provider, credential kind inferred, no toggle.**
Per the design, a single provider is kept and the raw credential value
is sent as-is; the credential kind (AI Studio key vs Vertex SA JSON) is
inferred server-side. The design allowed "inferred from the upload *or*
an explicit toggle"; I chose inference to keep the UI minimal and avoid
introducing a new user-facing gating control. No provider ids are added
or renamed, so the proxy's 1:1 path→provider mapping is unaffected.
- **Generated-file edit (bridge).** The `input_type`/`label` fields were
hand-applied to the generated
`packages/ingest-types/src/{types,zod}.gen.ts`. The upstream
`openapi.yaml` (the generation source) is not in this repo, so this
models the wire contract additively until the backend spec lands the
same optional fields; a future regeneration reproduces them. The fields
are optional, so nothing breaks if the server omits them.
- **No secret ever echoed back.** Only the create request carries the
value; GET/List responses (metadata-only) are unchanged.

Tests: unit coverage added/updated for the provider metadata
passthrough, `selectedInputType`, server label precedence, file loading,
JSON validation, and the form rendering both branches. Targeted vitest
(79 secrets tests), `pnpm typecheck`, eslint, and oxfmt all green.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:21:27 +00:00
imick-io
97fffb5394 [feat] Add NEW badge to top-level header nav items (#13527)
## Summary
Adds an accent NEW badge next to top-level header nav items (Products,
Community) on both desktop and mobile.

## Changes
- **What**: New `badge?: 'new'` field on `NavItem`, rendered as an
accent `Badge` in `HeaderMainDesktop.vue` and `HeaderMainMobile.vue`;
adds a `xxs` badge size and drops hardcoded padding/size from the
`accent` variant so it composes with size.
- **Tests**: e2e coverage asserting the badge shows only on
Products/Community, not Company/Pricing.

## Review Focus
- The `accent` badge variant now inherits padding/font-size from `size`
instead of hardcoding them — verify existing `accent` usages still
render correctly.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-13 16:39:15 +00:00
Rizumu Ayaka
4972e4d42f fix(form-dropdown): stop cancelling native scroll on slow trackpad wheels (#12334)
## Summary

Follow-up to #12052. The local wheel guard added in FormDropdownMenu was
cancelling native scroll on slow macOS trackpad gestures, leaving image
rows blank in the asset picker until a fast scroll re-pumped samples
through.

## Changes

- **What**: `FormDropdownMenu.onWheel` now only `preventDefault`s on
pinch-zoom (`ctrl/meta + wheel`). The `|deltaX| > |deltaY|` branch of
`isCanvasGestureWheel` is no longer used here.
- **Why**: Slow vertical trackpad scrolls emit small-delta frames with
stray horizontal jitter (e.g. `deltaY=1.2`, `deltaX=1.5`) that satisfied
the horizontal-dominant predicate. Cancelling those frames starved
`VirtualGrid`'s throttled `useScroll`, so `Math.floor(scrollY /
itemHeight)` never advanced and tiles never mounted.
- Horizontal-swipe-to-navigate is still blocked at the page boundary by
`overscroll-behavior: none` on `html, body` (the actual fix from
#12052), so dropping the local horizontal guard does not reintroduce the
back/forward bug.
- The global `isCanvasGestureWheel` is unchanged —
`useCanvasInteractions` still uses it for the canvas wheel handler,
where the semantics are different (forward to canvas pan, not
preventDefault native scroll).

## Review Focus

- Confirm `overscroll-behavior: none` on `html, body` is sufficient on
its own for the horizontal-swipe-to-navigate case inside the teleported
dropdown. (It is — overscroll-behavior is decided per scroll container
at gesture start, independent of where in the page the gesture
originates.)
- Pinch-zoom in the dropdown still preventDefaults correctly.

Tested on macOS trackpad: slow vertical scroll now loads images
progressively; fast scroll unchanged; pinch-zoom in dropdown does not
zoom the page; horizontal swipe in dropdown does not navigate.

Related: this fixes the slow-scroll repro reported in #bug-dump. The
blank-on-reopen class of bug from FE-535 (#11885) is a different issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12334-fix-form-dropdown-stop-cancelling-native-scroll-on-slow-trackpad-wheels-3656d73d3650813a987ffa8d6f0eaa9c)
by [Unito](https://www.unito.io)
2026-07-13 16:08:29 +00:00
Terry Jia
8d8ae7dfc0 feat: share one WebGL context across all 3D views (#13547)
## Summary
Browsers cap live WebGL contexts per page, so each Load3D node owning
its own renderer broke once enough 3D nodes were added. All 3D views now
share a single offscreen WebGLRenderer: each view renders into the
bottom-left region of the shared drawing buffer and blits the frame into
its own plain 2D canvas, which has no context limit.

- Add sharedWebGLRenderer (refcounted singleton, grow-only buffer) and
RendererView (per-view canvas + per-view renderer state + blit)
- Per-view renderer state (tone mapping, color space, clear color) is
applied before each view renders; HDRIManager and SceneModelManager
write to it instead of the renderer
- OrbitControls/TransformControls bind to the node container explicitly
- ViewHelperManager renders the axes helper itself so it can position
and scale independently of renderer DOM layout
- RecordingManager records from the view canvas; capture renders at
exact size on the shared buffer as before
- On last view release the context is force-lost and webglcontextlost is
dispatched synchronously so context-loss handlers still run

## Screenshots (if applicable)
before
<img width="2155" height="1527" alt="image"
src="https://github.com/user-attachments/assets/35a7ab8a-28dd-41ba-8349-525bd2e97977"
/>

after
<img width="918" height="430" alt="屏幕截图 2026-07-09 135025"
src="https://github.com/user-attachments/assets/67328577-bd82-4494-921a-6d298442d081"
/>
<img width="1636" height="1509" alt="image"
src="https://github.com/user-attachments/assets/a3be9215-7cd0-427c-8264-1fd964d3ace1"
/>
2026-07-13 09:50:30 -04:00
Pablo
38c034e1d6 feat(load3d): prototype top-bar chrome for embedded 3D viewer (#13205)
## Summary

Prototype redesign of the embedded 3D viewer controls into a framed
top/bottom-bar chrome. Proof-of-concept for design exploration — not
intended to merge as-is.

## Changes

- **What**: Replaces the floating viewer controls (`Load3DControls`)
with a new `Load3DMenuBar` framed chrome: a black top bar with a
category dropdown (Scene / 3D Model / Camera / Lighting) and the active
category's actions (labels collapse to icons on narrow nodes), plus a
black bottom bar with Record (left) and fit + export (right). Export is
moved out of the menu into the bottom-right button. Adds a "Clay"
material mode that renders meshes with a flat grey material so geometry
is visible without textures.
- **Breaking**: None — `Load3D.vue` swaps the component and hides its
existing right-side button column behind `v-if="false"`; nothing is
deleted.

## Review Focus

- Design prototype: `Load3D.vue` swaps `Load3DControls` →
`Load3DMenuBar` and hides the existing fit/center/expand/record column.
The original components are left untouched.
- The only changes outside the prototype component are for the **Clay**
material mode, which touches shared engine code: `MaterialMode` type
(`interfaces.ts`), the runtime material switch (`SceneModelManager.ts`),
the capability lists (`MeshModelAdapter.ts`, `ModelAdapter.ts`,
`useLoad3d.ts`), and the i18n label.
- Known prototype gaps: **Record** is a visual toggle only (pulsing dot,
not wired to real recording); **fit** reuses the existing
`handleFitToViewer`; **export** reuses the existing format options.

## Screenshots (if applicable)

Prototype walkthrough captured during development (top-bar chrome,
category dropdown, labeled-vs-icon collapse, Clay material, pulsing
Record). Available on request.

---------

Co-authored-by: PabloWiedemann <PabloWiedemann@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-12 23:09:54 -04:00
Comfy Org PR Bot
b40fad0e75 1.48.2 (#13596)
Patch version increment to 1.48.2

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-11 03:49:03 +00:00
Maanil Verma
01cbfa6a23 feat(templates): replace template search with MiniSearch and usage ranking (#13386)
## Summary

The template picker's search now surfaces the right template for how
people actually type — abbreviations, typos, multi-word intent, and
non-Latin (CJK) titles — and orders results by real popularity instead
of a fuzzy-match score that was being thrown away. Search and ranking
now behave the same here as they do on the workflow hub.

## Changes

**What**

- Searching for the way people phrase things now works: `t2v`, `i2v`,
`cn` expand to their full modality terms, `img2img`/`v2v` expand to
editing (matching how the catalog tags image/video edit templates),
`flux upscale` and `sdxl lora` match across title/model/tag fields
together, prefixes like `vid` match `video`, and typos like `contorlnet`
still find ControlNet. Versioned names tokenize sensibly, so `wan 2.2`
and `wan2.2` both hit, while `2.5` never blurs into `3.5`.
- CJK titles are searchable. Unspaced Han/Hiragana/Katakana runs are
tokenized into character unigrams and bigrams, so a substring a user
types (`放大` inside `图像放大`, or the single trailing `大`) lands on a match.
Korean and other spaced scripts fall to the normal word tokenizer,
unchanged.
- Fuzzy matching is tighter: a term now tolerates edits up to 20% of its
length (down from a flat threshold), so `contorlnet` still finds
ControlNet but `upscale` no longer fuzzy-matches the shorter, unrelated
`scale`. Short (≤3-char) and digit-bearing terms stay exact.
- Results lead with text relevance. Previously the fuzzy match score was
computed and then discarded, and any active sort re-ordered results by
usage — so the best textual match rarely landed on top. Now relevance is
the authoritative order while a query is active, and when two results
match about equally well, the more-used template wins the tie (dampened
so one runaway-popular template can't dominate).
- The ranking is a stable total order. Scores are bucketed before usage
breaks ties, so a cluster of near-equally-relevant results always sorts
the same way — a naive per-pair "within X%" comparison is intransitive
and makes the order depend on internal input order (it can even shuffle
as you type another character).
- "Popular" ranks by raw usage, matching what the hub and the search
index show. It previously blended in a freshness term that pushed newer,
less-used templates above genuinely popular ones.
- The sort dropdown works during search again: it defaults to
"Relevance" but you can switch to Popular/Newest/etc. to re-order the
results, and your browse sort is restored (and never overwritten by a
search-time choice) when you clear the query.
- Alphabetical sort reads correctly: it sorts by the title shown on the
card, trims stray leading whitespace that used to jump templates to the
top, and groups number-prefixed titles after the letters instead of
ahead of them.
- Filter telemetry now reports the sort the user is actually seeing
(relevance while searching) rather than the persisted browse sort, so
analytics reflect the visible ordering.
- Removed the old runtime Fuse-options override path, which is obsolete
under the new engine.

**Breaking**

None. Existing filters (Model / Use Case / Runs On / distribution),
pagination, and persisted sort settings are unchanged; the relevance
mode is search-only and never persisted.

## Review Focus

- The ranking crux is `rankByRelevanceThenUsage` in
`templateSearchConfig.ts`: relevance is primary, usage only re-orders
results in the same score bucket, and bucketing keeps it a stable total
order. That's the one function to review for correctness.
- The CJK tokenizer (`cjkGrams` / `tokenize` in
`templateSearchConfig.ts`): script-matched so only unspaced scripts are
grammed, and a pure-CJK run relies on its grams (no whole-word token).
Splitting by code point is safe here (these scripts are BMP-only; emoji
are excluded by the run regex).
- Deliberately not touched: the "Recommended" sort keeps its curated
blend (usage + editorial rank + freshness) so it stays distinct from
"Popular"; `vram-low-to-high` remains unimplemented exactly as on main.

## Tradeoffs / notes

- Adds `minisearch` (~18 kB gzip). The template selector is where it's
used; accepted for the search-quality gain (a later change could
lazy-load it if bundle size becomes a concern).
- Bucketing means two results just across a bucket boundary don't
tie-break on usage even when their scores are close — the accepted cost
of a transitive, predictable order (this mirrors how the search index
quantizes relevance).
- `img2img` expands to editing (not literal "image to image") because
the catalog labels those templates "Image Edit" — verified against the
real data.
- CJK bigrams roughly double the token count for a pure-CJK title;
negligible at catalog scale (~550 templates, short titles).

## Testing

Behavioral coverage over the real search paths, not the mocks — the
ranking and tokenizer run against actual MiniSearch output; only the
ranking-store math is mocked. Also verified against the full
~550-template catalog end to end (all query types stable, zero
input-order-dependent orderings).

### Behavior matrix (verified on the real catalog)

| Input / action | Now | Previously |
| --- | --- | --- |
| `img2img` | Image-editing templates (Qwen Image Edit, …) | Matched
every "image" template — intent lost |
| `flux upscale` | Flux upscale templates (matches both terms across
fields) | **No results** (single-field fuzzy couldn't span title + tag)
|
| `sdxl lora` | SDXL templates | **No results** |
| `t2v` / `i2v` / `cn` | Expand to text→video / image→video / controlnet
| Only partial slug hits, if any |
| `vid` (prefix) | Matches `video` templates | Unreliable |
| `contorlnet` (typo) | Finds ControlNet | Often dropped by the strict
threshold |
| `upscale` | Matches upscale titles only | Fuzzy-matched the unrelated
substring `scale` |
| `放大` / `大` (CJK) | Matches `图像放大` and other titles containing the run
| No match — CJK titles were unsearchable by substring |
| `wan 2.2` and `wan2.2` | Both match; `2.5` never matches `3.5` | Space
vs no-space degraded the match |
| Near-tied cluster (e.g. `upscale`) | Stable order every time |
Reordered depending on input order (could shuffle as you type) |
| Query active, "Popular" selected | Best textual match still leads;
usage breaks near-ties | Sort re-ordered by usage, burying the best
match |
| Change sort while searching | Re-orders the search results; relevance
is the default | Sort was locked; dropdown had no effect |
| Clear the search | Restores the browse sort you had before | — |
| "Popular" sort | Orders by raw usage (matches hub / index) | Freshness
blend pushed newer low-usage templates up |
| A–Z sort | Letters first (`ACE…`), number-prefixed titles last
(`3x3…`, `360…`); leading whitespace ignored | Leading-space titles
jumped to the top; numbers sorted before letters |

### Unit tests (81 total, all passing)

- `templateSearchConfig.test.ts` (34) — tokenizer identifier/version
splits, CJK unigram/bigram gramming (and Korean left as a spaced word),
per-term fuzziness (`upscale` ≠ `scale`), abbreviation expansion (incl.
`img2img`→edit intent), prefix + typo matching, AND-then-OR,
literal-before-expansion ordering, relevance>tag>description ranking,
and `rankByRelevanceThenUsage` giving a stable order on an intransitive
cluster.
- `useTemplateFiltering.test.ts` (35) — the `img2img` / `flux upscale` /
`sdxl lora` regressions, relevance-default-on-search,
override-sort-while-searching, browse-sort restore on clear, ephemeral
mid-search sort, telemetry reporting the visible sort, Runs-On filter,
empty-result guard, filters preserving relevance order, and alphabetical
trimming + numbers-after-letters.
- `templateRankingStore.test.ts` (12) — freshness and default-score
(recommended) math.

Gate: `pnpm typecheck`, `pnpm lint`, `pnpm knip` clean.

## Screen Recording (if applicable)



https://github.com/user-attachments/assets/6748a3f7-e69d-44ac-826c-71990c8dce90
2026-07-11 01:16:40 +00:00
Christian Byrne
945a143626 fix(ci): harden pr-backport — independent per-target attempts + comment on conflict failures (#13412)
## Symptom

When the auto-backport workflow (`.github/workflows/pr-backport.yaml`)
hits a cherry-pick **conflict**, it is supposed to comment on the
original PR telling the author to backport manually. On PR #13359
(backport to `cloud/1.45`, cherry-pick of `d6c582c39` conflicting on
`useSubscriptionDialog.test.ts`) **no comment was posted** — the author
(@huntcsg) got no notification and had to find the failure by digging
into Actions logs. See run
[28616756256](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/28616756256/job/84862400420).

## Root cause

The "Comment on failures" step actually ran and reached the `conflicts`
branch — the failure reason *was* populated and the `if:` condition
*was* met. The real failure is at the last line of the loop, under the
step's default `bash -e` shell:

```yaml
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
```

The job log ends with:

```
GraphQL: Unable to create comment because issue is locked (addComment)
##[error]Process completed with exit code 1.
```

PR #13359 is `locked: true`, so `gh pr comment` returns non-zero.
Because the call was unguarded under `set -e`, the step aborted on the
spot: the comment was lost and — critically for the general case — any
remaining failed targets in the loop would also be skipped. The step is
then marked failed with no actionable output on the PR.

(Related PR #13167 "attempt each backport target branch independently"
is still open/unmerged; this is a residual gap in the failure-comment
path.)

## Fix

Wrap every `gh pr comment` call in a `post_comment` helper. On failure
it emits a `::warning::` naming the target, the reason, and the
manual-backport branch (`backport-<pr>-to-<target>`) instead of
aborting, so the loop always attempts a comment for each failed target
and surfaces a clear log message when GitHub refuses (e.g. locked
issue). The conflict comment body now also states the manual backport
branch explicitly.

- YAML: `python3 -c 'import yaml; yaml.safe_load(...)'` passes.
- `actionlint`: no new warnings in the changed step (the 2 pre-existing
`SC2016` notes on the intentional single-quoted `envsubst` var lists are
unchanged).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:09:31 -07:00
pythongosssss
193bbaba81 fix: dont remove unowned callbacks when cleaning hooks on unmount (#12380)
## Summary

Minimaps unmounted cleanup blindly restored the callbacks that are
originally captured, even if other systems have chained their own
callbacks onto this, breaking other parts of the system (e.g. vue node
graph manager).
Recreation:    

1. Ensure minimap open
2. Enter subgraph 
3. Exit subgraph
4. Close minimap
5. Try adding a node/unpackign subgraph/etc <--- broken

## Changes

- **What**: 
- only replace callbacks that we own
- else function becomes no-op

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12380-fix-dont-remove-unowned-callbacks-when-cleaning-hooks-on-unmount-3666d73d3650817cbfe0d98ab98528b8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-07-10 20:24:20 +00:00
Alexis Rolland
1eacb224a1 Update commit author login retrieval in CLA workflow (#13544)
## Summary

Make CLA more robust by including commit authors in the allowlist even
if they have no GitHub account. This to ensure only PR authors are
required to sign.

## Changes

- **What**: `cla.yaml`
2026-07-10 20:16:51 +00:00
Terry Jia
4ed2fe70f3 feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13376)
## Summary
- Seed/override the canvas from an upstream bboxes input
- Add dotted grid background and magnetic snap-to-grid controls
- Darken the canvas well for contrast

BE is https://github.com/Comfy-Org/ComfyUI/pull/14724

## Screenshots (if applicable)



https://github.com/user-attachments/assets/7282f4d5-7cac-46f0-9d73-d0add37f4eb9
2026-07-10 15:40:17 +00:00
Terry Jia
5da5ee5031 feat: wire up Save 3D (Advanced) node family (CORE-329) (#13330)
## Summary
Register the save-side advanced nodes in the Load3D viewer
infrastructure: Save3DAdvanced reuses the mesh advanced extension, while
SaveGaussianSplat and SavePointCloud reuse the splat/point cloud preview
extensions.

Parameterize both extension factories with a loadFolder so save nodes
load the persisted file from the output folder instead of temp, and add
the node types to the lazy-load and viewport-state sets.

BE change https://github.com/Comfy-Org/ComfyUI/pull/14701
## Screenshots (if applicable)
Save 3D (Advanced)
<img width="1328" height="939" alt="image"
src="https://github.com/user-attachments/assets/c5f3cbe0-6e57-463c-9128-67490c2fc89e"
/>

Save Splat
<img width="1296" height="1052" alt="image"
src="https://github.com/user-attachments/assets/f05bbcf8-9794-4861-9dd7-3015d38b11d9"
/>
2026-07-10 15:39:51 +00:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
407 changed files with 17356 additions and 6653 deletions

View File

@@ -38,9 +38,11 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,32 +278,49 @@ jobs:
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
git push --force-with-lease origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
else
git push origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
# Return to main (keep the branch, we need it for PR)
git checkout main
git checkout main || git checkout -f main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort
git cherry-pick --abort || true
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main
git branch -D "${BACKPORT_BRANCH}"
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
fi
echo "::endgroup::"
@@ -384,6 +401,10 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -416,19 +437,37 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
elif [ "${reason}" = "already-exists" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -444,10 +483,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
fi
done

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers Comfy')
await expect(page).toHaveTitle('Careers - Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud supported by default'
'Custom-node packs on Comfy Cloud - supported by default'
)
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {

View File

@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop Run AI on Your Hardware'
'Download Comfy Desktop - Run AI on Your Hardware'
)
})

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {

View File

@@ -13,7 +13,7 @@ test.describe('Learning page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning Comfy')
await expect(page).toHaveTitle('Learning - Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
@@ -137,7 +137,7 @@ test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 Comfy')
await expect(page).toHaveTitle('学习 - Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)

View File

@@ -31,6 +31,26 @@ test.describe('Desktop navigation @smoke', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
@@ -117,6 +137,27 @@ test.describe('Mobile menu @mobile', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {

View File

@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful Comfy')
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expectNoIndex(page)
})
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed Comfy')
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expectNoIndex(page)
})
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 Comfy')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 Comfy')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 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 underline underline-offset-2 transition-opacity hover:opacity-70"
>{{ part.value }}</a
>
<template v-else>{{ part.value }}</template>
</template>
</p>
</section>
</div>

View File

@@ -8,23 +8,21 @@ 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 }
type CardLink = {
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
copyable?: string
link?: CardLink
}
type ColumnCount = 2 | 3 | 4
@@ -91,28 +89,30 @@ const columnClass: Record<ColumnCount, string> = {
{{ 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>
<div
v-if="card.copyable || card.link"
class="mt-6 flex flex-col items-start gap-4"
>
<CopyableField
v-else
:value="card.action.value"
v-if="card.copyable"
class="w-full"
:value="card.copyable"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
<Button
v-if="card.link"
as="a"
:href="card.link.href"
:target="card.link.target"
:rel="
card.link.target === '_blank' ? 'noopener noreferrer' : undefined
"
:variant="card.link.variant ?? 'outline'"
:append-icon="card.link.icon"
>
{{ card.link.label }}
</Button>
</div>
</div>
</div>

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
@@ -33,12 +33,14 @@ const {
heading,
eyebrow,
locale = 'en',
rows
rows,
mediaFit = 'cover'
} = defineProps<{
heading: string
eyebrow?: string
locale?: Locale
rows: readonly FeatureRow[]
mediaFit?: 'cover' | 'contain'
}>()
</script>
@@ -58,7 +60,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'
)
"
@@ -75,7 +77,7 @@ const {
<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,14 @@ 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 ?? mediaFit) === 'contain'
? 'object-contain'
: 'object-cover'
)
"
/>
<VideoPlayer
v-else
@@ -99,7 +108,8 @@ const {
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
:fit="mediaFit"
class="absolute inset-0 size-full"
/>
</div>
</GlassCard>

View File

@@ -16,6 +16,7 @@ import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
@@ -42,7 +43,10 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
{{ navItem.label }}
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
</span>
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">

View File

@@ -8,6 +8,7 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
@@ -96,7 +97,8 @@ onUnmounted(() => {
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
{{ item.label }}
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
<template #append>
<ChevronRight class="size-7" />
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
import NewBadge from './NewBadge.vue'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
@@ -12,9 +11,7 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import type { BadgeVariants } from '@/components/ui/badge'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
</script>
<template>
<Badge :size="size" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
</template>

View File

@@ -30,7 +30,8 @@ const {
autoplay = false,
loop = false,
minimal = false,
hideControls = false
hideControls = false,
fit = 'cover'
} = defineProps<{
locale?: Locale
src?: string
@@ -40,6 +41,7 @@ const {
loop?: boolean
minimal?: boolean
hideControls?: boolean
fit?: 'cover' | 'contain'
}>()
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
@@ -197,7 +199,9 @@ function toggleFullscreen() {
<video
v-if="src"
ref="videoEl"
class="size-full object-cover"
:class="
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
"
:src
:poster
:preload="autoplay ? 'auto' : 'metadata'"

View File

@@ -6,14 +6,15 @@ export const badgeVariants = cva({
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
xs: 'px-2 py-0.5 text-[9px]',
xxs: 'px-1.5 py-px text-[8px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -76,6 +76,7 @@ export const externalLinks = {
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
cloudMcpServer: 'https://cloud.comfy.org/mcp',
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',

View File

@@ -30,15 +30,23 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| { label: string; href: string; columns?: never; featured?: never }
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
@@ -95,6 +103,7 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

View File

@@ -823,7 +823,7 @@ const translations = {
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud supported by default',
en: 'Custom-node packs on Comfy Cloud - supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
@@ -1845,8 +1845,8 @@ const translations = {
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP 让任何 AI 智能体驱动 ComfyUI'
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
@@ -1931,9 +1931,9 @@ const translations = {
'zh-CN': '或手动添加'
},
'mcp.setup.step2.description': {
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in any client, then sign in when prompted. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
'想手动配置?在任意客户端中将此 URL 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
@@ -2037,14 +2037,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 +2130,80 @@ 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 any other agents, you need to connect with an 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对于其他智能体你需要使用 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, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw 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 不需要。Cursor、Hermes 和 OpenClaw 目前需要 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.',
'zh-CN':
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli.',
'zh-CN': '即将推出。目前,若要操作本地 ComfyUI你可以使用 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 to local 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
@@ -3483,8 +3531,8 @@ const translations = {
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms Comfy',
'zh-CN': 'Affiliate Terms Comfy'
en: 'Affiliate Terms - Comfy',
'zh-CN': 'Affiliate Terms - Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
@@ -3896,8 +3944,8 @@ const translations = {
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA Comfy',
'zh-CN': 'Enterprise MSA Comfy'
en: 'Enterprise MSA - Comfy',
'zh-CN': 'Enterprise MSA - Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
@@ -4361,8 +4409,8 @@ const translations = {
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 成为合作伙伴'
en: 'Comfy.org Affiliate Program - Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
@@ -4387,8 +4435,8 @@ const translations = {
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 6 月 29 日发布直播'
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
title="404 Page Not Found Comfy"
title="404 - Page Not Found - Comfy"
noindex>
<div
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">

View File

@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="About Us Comfy"
title="About Us - Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -42,7 +42,7 @@ const roles = itemListNode(
---
<BaseLayout
title="Careers Comfy"
title="Careers - Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Case Studies Comfy">
<BaseLayout title="Case Studies - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud AI in the Cloud"
title="Comfy Cloud - AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>

View File

@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="Pricing Comfy Cloud"
title="Pricing - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },

View File

@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="Contact Comfy"
title="Contact - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
const stories = (await loadStories('en')).map(toCardProps)
---
<BaseLayout title="Customer Stories Comfy">
<BaseLayout title="Customer Stories - Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} Comfy`}>
<BaseLayout title={`${entry.data.title} - Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos Comfy" description="Interactive demos and tutorials for ComfyUI.">
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="Download Comfy Desktop Run AI on Your Hardware"
title="Download Comfy Desktop - Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[

View File

@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
---
<BaseLayout title="Gallery Comfy">
<BaseLayout title="Gallery - Comfy">
<HeroSection />
<GallerySection client:load />
<ContactSection />

View File

@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
---
<BaseLayout
title="Comfy Professional Control of Visual AI"
title="Comfy - Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[

View File

@@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning Comfy">
<BaseLayout title="Learning - Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
---
<BaseLayout
title="Models Comfy"
title="Models - Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection

View File

@@ -103,7 +103,7 @@ const faqPage: JsonLdNode = {
---
<BaseLayout
title={`${pageTitle} Comfy`}
title={`${pageTitle} - Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}

View File

@@ -43,7 +43,7 @@ const dirLabel: Record<string, string> = {
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Failed Comfy"
title="Payment Failed - Comfy"
description="Your payment was not completed."
noindex
>

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Successful Comfy"
title="Payment Successful - Comfy"
description="Your payment was processed successfully."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue'
---
<BaseLayout
title="Privacy Policy Comfy"
title="Privacy Policy - Comfy"
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy Comfy"
title="Desktop Privacy Policy - Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />

View File

@@ -6,7 +6,7 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service Comfy"
title="Terms of Service - Comfy"
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Videos Comfy">
<BaseLayout title="Videos - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -16,7 +16,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="关于我们 Comfy"
title="关于我们 - Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}

View File

@@ -42,7 +42,7 @@ const roles = itemListNode(
---
<BaseLayout
title="招聘 Comfy"
title="招聘 - Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="案例研究 Comfy">
<BaseLayout title="案例研究 - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud 云端 AI"
title="Comfy Cloud - 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>

View File

@@ -20,7 +20,7 @@ const productId = jsonLdId(url, 'product')
---
<BaseLayout
title="定价 Comfy Cloud"
title="定价 - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{

View File

@@ -13,7 +13,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="联系我们 Comfy"
title="联系我们 - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../../utils/loadStories'
const stories = (await loadStories('zh-CN')).map(toCardProps)
---
<BaseLayout title="客户故事 Comfy">
<BaseLayout title="客户故事 - Comfy">
<HeroSection locale="zh-CN" client:load />
<StorySection stories={stories} locale="zh-CN" />
<FeedbackSection locale="zh-CN" client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} Comfy`}>
<BaseLayout title={`${entry.data.title} - Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -58,7 +58,7 @@ const learningResource: JsonLdNode = {
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="演示 Comfy" description="ComfyUI 的互动演示和教程。">
<BaseLayout title="演示 - Comfy" description="ComfyUI 的互动演示和教程。">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">

View File

@@ -23,7 +23,7 @@ const { siteUrl, locale } = pageContext(
---
<BaseLayout
title="下载 Comfy 桌面版 在您的硬件上运行 AI"
title="下载 Comfy 桌面版 - 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[

View File

@@ -5,7 +5,7 @@ import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
---
<BaseLayout title="作品集 Comfy">
<BaseLayout title="作品集 - Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />

View File

@@ -24,7 +24,7 @@ const { siteUrl } = pageContext(
---
<BaseLayout
title="Comfy 视觉 AI 的最强可控性"
title="Comfy - 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}

View File

@@ -11,7 +11,7 @@ import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 Comfy">
<BaseLayout title="学习 - Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
---
<BaseLayout
title="模型 Comfy"
title="模型 - Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付失败 Comfy" description="您的支付未能完成。" noindex>
<BaseLayout title="支付失败 - Comfy" description="您的支付未能完成。" noindex>
<PaymentStatusSection status="failed" locale="zh-CN" />
</BaseLayout>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付成功 Comfy" description="您的支付已成功完成。" noindex>
<BaseLayout title="支付成功 - Comfy" description="您的支付已成功完成。" noindex>
<PaymentStatusSection status="success" locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="隐私政策 Comfy"
title="隐私政策 - Comfy"
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 Comfy"
title="Desktop 隐私政策 - Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="视频 Comfy">
<BaseLayout title="视频 - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -61,7 +61,12 @@ const { drop, locale } = defineProps<{
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
<Badge
v-if="drop.badge"
size="xs"
variant="accent"
class="absolute top-6 left-8"
>
{{ drop.badge[locale] }}
</Badge>
</CardContent>

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

@@ -15,21 +15,18 @@ const cards: FeatureCard[] = [
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
)
}
copyable: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
},
{
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',
copyable: externalLinks.cloudMcpServer,
link: {
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
@@ -42,8 +39,7 @@ const cards: FeatureCard[] = [
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
link: {
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',

View File

@@ -7,7 +7,7 @@ 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
@@ -16,12 +16,17 @@ type ToolMedia =
hideControls?: boolean
}
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: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
src: 'https://media.comfy.org/website/mcp/generate-everything.gif',
fit: 'cover'
},
altKey: 'mcp.tools.1.alt'
},
@@ -29,7 +34,8 @@ const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
n: 2,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png',
fit: 'cover'
},
altKey: 'mcp.tools.2.alt'
},
@@ -43,6 +49,35 @@ const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
hideControls: true
},
altKey: 'mcp.tools.3.alt'
},
{
n: 4,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/direct-any-model.png',
fit: 'cover'
},
altKey: 'mcp.tools.4.alt'
},
{
n: 5,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-in-batches.png',
fit: 'cover'
},
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
},
altKey: 'mcp.tools.6.alt'
}
]
@@ -62,5 +97,6 @@ const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
:locale="locale"
:heading="t('mcp.tools.heading', locale)"
:rows="rows"
media-fit="contain"
/>
</template>

View File

@@ -28,6 +28,7 @@ import {
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
SidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -70,6 +71,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _appsTab: SidebarTab | null = null
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
@@ -104,6 +106,11 @@ class ComfyMenu {
return this._nodeLibraryTabV2
}
get appsTab() {
this._appsTab ??= new SidebarTab(this.page, 'apps')
return this._appsTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab

View File

@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
export class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator

View File

@@ -0,0 +1,40 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The graph/app view-mode toggle and its workflow actions dropdown.
* A separate instance mounts in each host - the subgraph breadcrumb (graph
* mode) and the app-mode center panel - and unmounts as the mode flips.
*/
export class WorkflowActionsDropdown {
/** The segmented graph/app toggle hosting the workflow actions trigger. */
public readonly viewModeToggle: Locator
/** The active segment; opens the workflow actions menu. */
public readonly trigger: Locator
/** The inactive segment that switches into app mode. */
public readonly enterAppModeSegment: Locator
/** The inactive segment that switches back to the node graph. */
public readonly enterGraphSegment: Locator
/** The workflow actions dropdown menu. */
public readonly menu: Locator
constructor(page: Page) {
this.viewModeToggle = page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
this.trigger = this.triggerIn(this.viewModeToggle)
this.enterAppModeSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter app mode'
})
this.enterGraphSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter node graph'
})
this.menu = page.getByRole('menu', { name: 'Workflow actions' })
}
/** The trigger as rendered inside a specific mode's host. */
triggerIn(host: Locator): Locator {
return host.getByRole('button', { name: 'Workflow actions' })
}
}

View File

@@ -4,6 +4,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { WorkflowActionsDropdown } from '@e2e/fixtures/components/WorkflowActionsDropdown'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -19,6 +20,7 @@ export class AppModeHelper {
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
readonly workflowActions: WorkflowActionsDropdown
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
@@ -77,6 +79,7 @@ export class AppModeHelper {
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.workflowActions = new WorkflowActionsDropdown(comfyPage.page)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
@@ -185,10 +188,7 @@ export class AppModeHelper {
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.workflowActions.trigger.click()
await this.page
.getByRole('menuitem', { name: /Build app|Edit app/ })
.click()

View File

@@ -239,6 +239,9 @@ export const TestIds = {
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
workflowActions: {
viewModeToggle: 'view-mode-toggle'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
@@ -276,6 +279,7 @@ export const TestIds = {
overlay: 'loading-overlay'
},
load3d: {
categoryMenu: 'load3d-category-menu',
recordingDuration: 'load3d-recording-duration'
},
load3dViewer: {

View File

@@ -1,9 +1,14 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
test.describe('App mode usage', () => {
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
const { centerPanel } = comfyPage.appMode
@@ -137,6 +142,117 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shows a single side toolbar per mode, filtered to assets + apps in app mode', async ({
comfyPage
}) => {
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toHaveCount(1)
await expect(nodeLibraryTab.tabButton).toBeVisible()
})
await test.step('App mode shows only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(sideToolbar).toHaveCount(1)
await expect(assetsTab.tabButton).toBeVisible()
await expect(appsTab.tabButton).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage,
subgraphBreadcrumb
}) => {
const { workflowActions, centerPanel } = comfyPage.appMode
// Toggling graph<->app mode happens from this control, so it must not move
// out from under the cursor as the mode flips.
const graphActions = workflowActions.triggerIn(
subgraphBreadcrumb.panel.root
)
await expect(graphActions).toBeVisible()
const graphBox = await graphActions.boundingBox()
expect(graphBox).not.toBeNull()
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel).toBeVisible()
const appActions = workflowActions.triggerIn(centerPanel)
await expect(appActions).toBeVisible()
// The toggle segments reorder (morph) as the mode flips, so poll until the
// active control settles at the same x it occupied in graph mode.
await expect
.poll(async () => {
const box = await appActions.boundingBox()
return box ? Math.abs(box.x - graphBox!.x) : Infinity
})
.toBeLessThanOrEqual(1)
})
test('Toggle segment flips mode without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await expect(workflowActions.viewModeToggle).toBeVisible()
await workflowActions.enterAppModeSegment.click()
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The inactive segment switches mode; it must not also open the actions menu.
await expect(workflowActions.menu).toBeHidden()
await expect(workflowActions.viewModeToggle).toBeVisible()
})
test('Toggle segment flips mode via keyboard without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await workflowActions.enterAppModeSegment.focus()
await workflowActions.enterAppModeSegment.press('Enter')
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(workflowActions.menu).toBeHidden()
await expect(workflowActions.trigger).toBeFocused()
})
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enableLinearMode()
await expect(toggle).toBeVisible()
await comfyPage.appMode.enterBuilder()
await expect(toggle).toBeHidden()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
await comfyPage.appMode.footer.exitButton.click()
// Exiting the builder lands in graph mode: the app-mode-only center panel
// stays hidden while the graph-mode toggle host re-mounts and the toggle
// re-appears.
await expect(toggle).toBeVisible()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(toggle).toBeVisible()
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
await comfyPage.menu.assetsTab.tabButton.click()
await expect(toggle).toBeVisible()
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -11,39 +11,47 @@ export class Load3DHelper {
}
get menuButton(): Locator {
return this.node.getByRole('button', { name: /show menu/i })
return this.node.getByTestId(TestIds.load3d.categoryMenu)
}
private get menuPanel(): Locator {
return this.node.page().getByRole('dialog')
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: /start recording/i })
return this.node.getByRole('button', { name: 'Record', exact: true })
}
get stopRecordingButton(): Locator {
return this.node.getByRole('button', { name: /stop recording/i })
}
get exportRecordingButton(): Locator {
return this.node.getByRole('button', { name: /export recording/i })
}
get clearRecordingButton(): Locator {
return this.node.getByRole('button', { name: /clear recording/i })
}
get recordingDuration(): Locator {
get recordingMenuButton(): Locator {
return this.node.getByTestId(TestIds.load3d.recordingDuration)
}
get downloadRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
}
get startNewRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
}
get deleteRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
}
get gridToggleButton(): Locator {
return this.node.getByRole('button', { name: /show grid/i })
}
get uploadBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /upload background image/i })
return this.node.getByRole('button', { name: 'BG Image' })
}
get removeBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /remove background image/i })
return this.node.getByRole('button', { name: 'Remove BG' })
}
get panoramaModeButton(): Locator {
@@ -54,6 +62,10 @@ export class Load3DHelper {
return this.node.locator('input[type="color"]')
}
get exportButton(): Locator {
return this.node.getByRole('button', { name: 'Export', exact: true })
}
get openViewerButton(): Locator {
return this.node.getByRole('button', { name: /open in 3d viewer/i })
}
@@ -63,11 +75,15 @@ export class Load3DHelper {
}
getMenuCategory(name: string): Locator {
return this.node.getByText(name, { exact: true })
return this.menuPanel.getByRole('button', { name, exact: true })
}
get gizmoToggleButton(): Locator {
return this.node.getByRole('button', { name: 'Gizmo' })
// The category chip is also named "Gizmo" once that category is active;
// only the toggle carries aria-pressed.
return this.node
.getByRole('button', { name: 'Gizmo' })
.and(this.node.locator('[aria-pressed]'))
}
get gizmoTranslateButton(): Locator {
@@ -83,13 +99,17 @@ export class Load3DHelper {
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset Transform' })
return this.node.getByRole('button', { name: 'Reset', exact: true })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openRecordingMenu(): Promise<void> {
await this.recordingMenuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()

View File

@@ -47,10 +47,12 @@ test.describe('Load3D', () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
await expect(load3d.exportButton).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
@@ -253,7 +255,7 @@ test.describe('Load3D', () => {
}
)
test('Recording controls show stop/export/clear buttons after a recording', async ({
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
comfyPage,
load3d
}) => {
@@ -265,20 +267,25 @@ test.describe('Load3D', () => {
await expect(load3d.stopRecordingButton).toBeVisible()
})
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
// Record for 1s wall-clock so the duration display settles on 00:01.
await comfyPage.delay(1000)
await load3d.stopRecordingButton.click()
await expect(load3d.recordingButton).toBeVisible()
await expect(load3d.exportRecordingButton).toBeVisible()
await expect(load3d.clearRecordingButton).toBeVisible()
await expect(load3d.recordingDuration).toHaveText('00:01')
await expect(load3d.recordingMenuButton).toBeVisible()
await expect(load3d.recordingMenuButton).toHaveText('00:01')
})
await test.step('Clear recording removes export and clear controls', async () => {
await load3d.clearRecordingButton.click()
await expect(load3d.exportRecordingButton).toHaveCount(0)
await expect(load3d.clearRecordingButton).toHaveCount(0)
await test.step('Chip menu offers download, re-record and delete actions', async () => {
await load3d.openRecordingMenu()
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
})
await test.step('Deleting the recording restores the record button', async () => {
await load3d.deleteRecordingMenuItem.click()
await expect(load3d.recordingMenuButton).toHaveCount(0)
await expect(load3d.recordingButton).toBeVisible()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -46,7 +46,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
// Center the node so its header clears the view-mode toggle floating
// at the top-left of the canvas.
const nodeRef = await comfyPage.nodeOps.getNodeRefById(nodeId)
await nodeRef.centerOnNode()
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

View File

@@ -476,6 +476,37 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
.toBe(true)
})
test(
'Closing minimap after subgraph navigation keeps Vue render in sync',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await comfyPage.subgraph.exitViaBreadcrumb()
// Minimap unmount must not clobber the Vue wrapper layered above it.
await comfyPage.page
.getByTestId(TestIds.canvas.closeMinimapButton)
.click()
const subgraphFixture =
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
await comfyPage.contextMenu.waitForHidden()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
await expect(
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
).toHaveCount(0)
}
)
})
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

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