Compare commits

..

21 Commits

Author SHA1 Message Date
Marwan Mostafa
619d6d3bf9 feat(website): interactive color + lighting nodes drive the live hero output
Fill the empty Color Remixer and Lighting nodes with real controls (swatches,
mood presets, intensity sliders, a light-direction pad) that grade the OUTPUT
in real time via CSS overlays and filters. Add a live metadata bar, active node
glow, and connectors that pulse toward the output when a node is engaged.

Rework the mobile hero into a compact connected graph that fits one screen:
IMAGE forks into Color + Lighting which merge into the live output. Slow the
headline liquid link and respect prefers-reduced-motion throughout.
2026-06-30 13:41:57 +03:00
Marwan Mostafa
f6099e418b feat(website): tighten mobile hero to lead with output and remix picker
Collapse the mobile hero into a single OUTPUT card that leads with the
result and keeps the input thumbnails directly beneath it, so a tap swaps
the visible output with no scroll. Add a remix hint and thin the headline's
liquid connector.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
95d5127d5b feat(website): drag hero nodes and animate the split headline
- Make graph nodes draggable with live-routed wires; suppress the native
  image drag-out on the IMAGE node while keeping its picker clickable
- Rewire inputs into an image->texture->color->lighting->output chain and
  route side-port wires horizontally; extract the wire geometry into a
  tested heroGraphWires module
- Split the headline into two highlighter lines joined by a liquid link
  that glides side to side through the goo filter
- Nudge the whole graph left so the OUTPUT node bleeds less to the right
2026-06-30 13:41:56 +03:00
Marwan Mostafa
7a145d2fc6 fix(website): liquid headline, smoother wires, less output bleed
Move the gooey liquid effect onto the headline so the two highlighter blocks
merge into one liquid shape (crisp text layered over a goo-filtered yellow
backing); the IMAGE->TEXTURE link is a plain wire again. Cap the wire control
offsets so the splines stay smooth without twirling. Pull the OUTPUT node in so
most of it stays on screen, and keep the headline optically centered.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
a7cc2f87bf fix(website): rebalance hero layout, center headline, liquid links
Fill the viewport width on large screens instead of centering with margins, so
the IMAGE node sits near the left edge and the OUTPUT bleeds off the right
(no more right-leaning gap). Widen the design stage and move the headline into
the clear gap between IMAGE and OUTPUT, with the COLOR/LIGHTING placeholders
dropped to the lower band so nothing overlaps the copy. Optically center the
headline text inside the highlighter blocks. Replace the wiggly bezier wires
with tangent-aware S-curves plus endpoint dots, and render the IMAGE->TEXTURE
connection as a gooey liquid link.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
969e32afc1 feat(website): make hero responsive across all screens
Lay the desktop node graph out in a fixed 1440x760 design stage that scales
as a single unit to fit any width, so nodes and wires never collide and the
OUTPUT bleed is preserved proportionally from 1024px up. Wires are measured in
design coordinates via layout offsets, independent of the scale transform.
Extract the headline into HeroHeadline (shared by both layouts) and give phones
and tablets a centered, width-capped stacked column.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
949af59c4f feat(website): interactive node-graph hero
Replace the homepage hero with a free-floating node-graph behind a yellow
highlighter headline. The IMAGE node is a hover-driven image picker: hovering
(or focusing) a thumbnail crossfades the large input and cascades the matching
result through the OUTPUT node, with a smooth chromatic transition.

- New HeroGraph/HeroGraphNode/HeroImagePicker components and heroGraphData
- SVG bezier wires anchored to measured node rects, recomputed on resize
- Per-variant input/output image pairs; graceful stacked mobile layout
- Remove the old canvas hero (useHeroLogo) and the unused three dependency
- e2e coverage for hover/keyboard selection and the output cascade
2026-06-30 13:41:55 +03:00
Rizumu Ayaka
f4e0430072 fix: disable global keybindings while a modal dialog is open (#12184)
## Summary

Block background keybindings from firing while a modal dialog (e.g.
Templates) is open, so typing `w` no longer toggles the workflow sidebar
behind the modal.

## Changes

- **What**: In `keybindingService.keybindHandler`, gate command
execution on `dialogStore.dialogStack`. When a dialog is open, only
keybindings whose event target is inside the dialog (`[role="dialog"]`)
fire; all other matches are dropped.

## Review Focus

- The dialog scope check uses `target.closest('[role="dialog"]')` so
dialog-internal shortcuts still work — confirm PrimeVue/Reka dialogs
render with `role="dialog"` on the wrapper (they do; this is the
WAI-ARIA standard the libraries follow).
- Updated `keybindingService.escape.test.ts` "modifiers regardless of
dialog state" case to the new contract (modifiers also blocked),
matching the team consensus in FE-642 that all keybindings should be
disabled when a modal is open.
- New `keybindingService.dialog.test.ts` covers: no-dialog → fires;
dialog open + target outside → blocked; dialog open + target inside →
fires.

Fixes FE-642

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12184-fix-disable-global-keybindings-while-a-modal-dialog-is-open-35e6d73d3650812fbc5dd5490ccde24f)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-06-30 06:33:33 +00:00
Rizumu Ayaka
c78592c1ec feat: add upload button to dropdown menu filter bar (#12507)
## Summary

Add an Upload button to the dropdown popover's filter bar so users can
pick a file without closing the dropdown to reach the small upload icon
next to the input.

The upload button in the dropdown menu includes text and uses the same
icon as the external quick upload button. This design ensures that after
using it, users will understand that the icon on the external button
means upload. Even if users didn't understand it before, they will
correctly interpret it next time.

related linear FE-581

## Changes

- **What**:
- Expose `showPicker()` from `FormDropdownInput`; it calls
`HTMLInputElement.showPicker()` on the single existing hidden `<input
type="file">` (falls back to `input.click()` on browsers without
showPicker).
- Add an Upload button in `FormDropdownMenuFilter` that emits
`show-picker`, bubbled up through `FormDropdownMenu` to `FormDropdown`,
which then calls `triggerRef.showPicker()`. The whole chain runs in the
click event's synchronous stack to satisfy the browser's transient
activation requirement, so no extra `<input type="file">` is added to
the DOM.
- Style the button with the project's standard inverted-button tokens
(`bg-base-foreground` / `text-base-background`) so it tracks theme
changes.

## Review Focus

- The `triggerRef!.showPicker()` non-null assertion in
`FormDropdown.vue` is intentional: by the time `show-picker` is emitted
the trigger is guaranteed to be mounted; a null here would indicate a
real bug we want to surface, not swallow.
- Verify the new button reuses the same upload path as the inline icon
button (single `<input type="file">`, single `handleFileChange`).

## Screenshots

<img width="1304" height="1442" alt="CleanShot 2026-06-02 at 14 39
33@2x"
src="https://github.com/user-attachments/assets/b2d1cdd8-e28a-467d-8142-afd707264d0e"
/>


<details><summary>Old Versions</summary>
<p>


https://github.com/user-attachments/assets/2d64873b-6bec-4eca-aa89-a72dd11aa809

</p>
</details>
2026-06-30 06:25:24 +00:00
Rizumu Ayaka
00b0c6b434 fix: close widget dropdown on outside pointerdown and canvas viewport moves (#12812)
## Summary

Model/widget dropdowns stayed open until mouseup, detached from their
node when the canvas moved while open, and needed two clicks to dismiss
after the inner scrollbar took focus.

## Changes

- **What**:
- Dismiss the dropdown on `pointerdown` outside the menu/trigger
(capture phase) instead of PrimeVue's `click` (mouseup) dismissal. The
dropdown now closes the instant a press lands, before a drag or
box-select can start, and a focused inner scrollbar no longer swallows
the first outside click.
- Close the dropdown whenever the canvas viewport moves, by watching the
reactive `useTransformState().camera`. This reacts to the canvas
abstraction layer rather than guessing input intent, so it covers
pan/zoom from any device — mouse drag, trackpad pan, wheel scroll/zoom —
where no `pointerdown` ever fires. The popover is teleported to the
document body and cannot follow the viewport, so closing is the correct
behavior.

## Review Focus

- Box-select and node-drag both begin with a `pointerdown` outside the
popover, so they are covered by the immediate dismissal path; the camera
watch handles pointer-less viewport motion.
- `closeOnEscape` and in-menu interactions are unaffected; presses
inside the menu or on the trigger are excluded via `composedPath()`.

Fixes FE-808

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-30 05:58:55 +00:00
Christian Byrne
da34fa3944 docs(website): update ToS payment terms for Free Tier overages (#13315)
*PR Created by the Glary-Bot Agent*

---

Updates two sections on https://comfy.org/terms-of-service per legal
copy provided in [the website-and-docs Slack
thread](https://comfy-org.slack.com/archives/C098QHJ8YDR/p1782775899132369).

## Changes

Edits `apps/website/src/i18n/translations.ts` (the source of truth for
the ToS page rendered by
`apps/website/src/pages/terms-of-service.astro`):

- **`tos.payment.block.1` — Plans; Fees; Free Tier.** Adds language
clarifying that a Free Tier user who provides a payment method expressly
authorizes Comfy to charge it for overages (intentional use, third-party
use, or technical factors), and that approach-to-cap notifications are
best-effort, not a precondition to charging.
- **`tos.payment.block.3` — Self-Serve Credit Card Billing.** Clarifies
that the billing authorization applies to paid Plan and Free Tier
overages alike, and that retry rights for failed charges extend to Free
Tier overage charges.

`en` and `zh-CN` values are kept in sync per the existing convention for
these keys (the `/zh-CN/terms-of-service` page is a redirect to the
English page).

## Open question for legal / requester

`tos.effectiveDate` is currently `May 13, 2026` and was **not** bumped
in this PR — the original request did not mention it. If legal wants
this revision to carry a new effective date, that should be a follow-up
commit on this branch before merge.

## Verification

- `pnpm typecheck` (apps/website): 0 errors, 0 warnings.
- `pnpm build` (apps/website): 497 pages built; the rendered
`/terms-of-service` HTML contains both new sentences.
- `pnpm exec eslint` / `oxfmt --check` on the changed file: clean.
- Husky pre-commit (`lint-staged` + `check-unused-i18n-keys`): clean.
- Manual: served the built `dist/` via local HTTP and verified the
rendered Payment section in a real browser (screenshot below).

## Screenshots

![Rendered /terms-of-service Payment section showing the updated Plans;
Fees; Free Tier and Self-Serve Credit Card Billing
copy](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3d085431f019603d3250f274fdae4f9186eaaecbdaee4cbc6b924e2b84854661/pr-images/1782796953351-4deec91c-ac02-4bc5-b8cd-cd0a3413613e.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-30 05:29:13 +00:00
Alexander Piskun
c8ed15da31 feat: follow --comfy-api-base for staging and preview backends (#13054)
## Summary

Let the running ComfyUI server decide which backend the web UI talks to
(and which Firebase project it signs you into), so launching with
`--comfy-api-base` just works with the regular bundled frontend.

## Changes

- **What**: At startup the frontend reads `/api/features` on every build
(not just cloud) and treats the server's `comfy_api_base_url` /
`comfy_platform_base_url` as authoritative, falling back to the
build-time defaults.
When that api base is a staging-tier host (staging, or a
`*.testenvs.comfy.org` preview env) and the server hasn't supplied its
own Firebase config, the frontend picks the dev Firebase project,
derived from the api base.
Production is left exactly as it is today.
- `main.ts`: load remote config first thing, before Firebase
initializes, so every module sees the right values from the first render
- `config/comfyApi.ts`: the api/platform getters now read the server's
values on all distributions
- `config/firebase.ts`: `getFirebaseConfig()` resolves in order: a
server-provided config first (cloud), then the dev project for a
staging-tier api base, then the build-time default
- `platform/remoteConfig/refreshRemoteConfig.ts`: the startup fetch now
has a 5s timeout, so a slow or wedged `/features` can never keep the app
from mounting; on failure we fall back to the build-time defaults
- **Breaking**: None. With no `/features` overrides (production and
ordinary self-hosting), behavior is unchanged

## Review Focus

- The precedence in `getFirebaseConfig()` (`config/firebase.ts`): server
config first, then the staging-tier dev project, then the build-time
default. The staging-tier check matches `stagingapi.comfy.org` and any
`*.testenvs.comfy.org` host, and falls back to build-time for anything
it can't parse.
- Running `refreshRemoteConfig()` unconditionally and first in
`main.ts`, with the new fetch timeout as the safety net.

## Testing

I tested every case by hand, locally, on top of the automated checks.
Tested both with `pnpm run build` and `USE_PROD_CONFIG=true pnpm build`
and running Comfy from that folder.

Pointed a local ComfyUI at each backend with `--comfy-api-base` and
signed in with Google each time:

- **Production** (default / `https://api.comfy.org`): stays on
production and signs into the production Firebase project, identical to
today.
- **Staging** (`https://stagingapi.comfy.org`): follows it and signs
into the dev project.
- **Ephemeral preview env** (`https://pr-<n>.testenvs.comfy.org`): the
friendly host is accepted as-is, the frontend follows it, lands in the
dev project, and Google sign-in completes.

The only exception where fronted does not respect the `--comfy-api-base`
is when Comfy runs against `prod` and frontend runs with the `pnpm run
dev` - due to overridden config(this is expected behavior).

Supersedes: https://github.com/Comfy-Org/ComfyUI_frontend/pull/12560
Companion Core PR: https://github.com/Comfy-Org/ComfyUI/pull/14569

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
2026-06-30 05:18:24 +00:00
CodeJuggernaut
b132abc64a fix: center video asset in the Load Video node preview (#13172)
## Summary

Center the Load Video node preview and keep the node from auto-resizing
on clip load, so the video letterboxes in place like the Load Image node
(FE-1092).

## Changes

- **What**: Makes the video stay centered horizontally and vertically
- Playwright browser test expectations updated at run
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/28267324092

## Review Focus

- Ensure that patterns and idioms were followed

## Screenshots (if applicable)
Horizontally centered
<img width="902" height="392" alt="image"
src="https://github.com/user-attachments/assets/a9fbec56-1613-44b4-a423-9f709a246c63"
/>

Vertically centered
<img width="220" height="1124" alt="image"
src="https://github.com/user-attachments/assets/5497f39b-2ea2-4247-a087-a7d89768b4ce"
/>

Full aspect ratio
<img width="433" height="672" alt="image"
src="https://github.com/user-attachments/assets/d579fb14-34c6-4963-abc9-034611232d3d"
/>

Minimum size
<img width="217" height="376" alt="image"
src="https://github.com/user-attachments/assets/80df0411-3ff1-4050-ac8e-761b7b8a7c40"
/>


Preview centering is asserted in
`browser_tests/tests/vueNodes/videoPreview.spec.ts`.

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-06-30 02:59:11 +00:00
steven-comfy
55c52a730a Enable cloud PostHog pageviews (#13286)
## Summary

This PR enables native PostHog `$pageview` capture for `cloud.comfy.org`
by setting cloud PostHog `capture_pageview` to `history_change`.

This keeps `autocapture` disabled, preserves the existing custom
`app:page_view` event, and lets the PostHog SDK capture the initial
pageview plus SPA history navigation pageviews. The goal is to make
cross-domain funnel tracking cleaner between `comfy.org` and
`cloud.comfy.org`, since `comfy.org` already emits native `$pageview`
events.

## Why

We want to measure the visitor funnel more accurately across:

- `comfy.org` visits
- `cloud.comfy.org` visits
- signup clicks / signup opened
- signup completion
- first cloud workflow run
- first subscription
- first credit purchase

Using native `$pageview` on both website and cloud should make PostHog
and downstream warehouse/Hex analysis cleaner for trackable users, while
leaving custom app pageview telemetry intact for existing consumers.

## Validation

- `pnpm test:unit
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts`
- `pnpm typecheck`
- `pnpm lint:unstaged`
- pre-commit hook: `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`
- pre-push hook: `knip --cache`

Note: local validation printed an engine warning because the Codex
runtime has Node `v24.14.0` while this repo declares `>=25 <26`; the
commands above still passed.
2026-06-30 00:27:13 +00:00
Denis
fbe462143a fix: re-export GroupNodeHandler for custom node compat (#13299)
Fixes #13175

#12931 slimmed groupNode.ts down to migration-only and dropped the
export on GroupNodeHandler.

ComfyUI-Manager still imports it (import { GroupNodeConfig,
GroupNodeHandler } from "../../extensions/core/groupNode.js" in
components-manager.js), so the legacy shim no longer providing that
export throws "does not provide an export named 'GroupNodeHandler'" at
module load. That kills the whole Manager extension before setup() runs
— which is why the Manager button vanished from the toolbar since 1.47.3
(backend loads fine, frontend JS dies).

Just re-adds the export (class is still there, only the keyword was
lost) plus the existing @knipIgnoreUnusedButUsedByCustomNodes tag since
nothing in src imports it.

Tested by loading with ComfyUI-Manager installed: the groupNode.js
import error is gone and the Manager button shows again.
typecheck/knip/lint pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:04:59 +00:00
nav-tej
61cb1bcde0 fix(website): point launches Comfy MCP CTA to /mcp (#13287)
*PR Created by the Glary-Bot Agent*

---

## Summary

Update the `EXPLORE` CTA on the Comfy MCP card on
[/launches](https://comfy.org/launches) to link to
[/mcp](https://comfy.org/mcp) instead of the docs
(`docs.comfy.org/agent-tools/cloud`).

## Change

Single line in `apps/website/src/data/drops.ts`:

```diff
     cta: {
       label: EXPLORE,
-      href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
+      href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
     }
```

Matches the locale-aware pattern used by sibling cards (`/download` /
`/zh-CN/download`, `/api` / `/zh-CN/api`). Both `src/pages/mcp.astro`
and `src/pages/zh-CN/mcp.astro` already exist, so neither link is dead.
The `externalLinks.docsMcp` constant is retained because the MCP page
itself still uses it.

## Verification

- `pnpm typecheck` / `pnpm typecheck:website` clean.
- `oxfmt`, `oxlint`, `eslint` clean (all ran via lint-staged on commit).
- Manually loaded `/launches` and `/zh-CN/launches` in the dev server
and confirmed the Comfy MCP card now points to `/mcp` and `/zh-CN/mcp`
respectively.
- Loaded `/mcp` and confirmed the destination page renders ("Comfy MCP —
Drive ComfyUI from any AI agent").
- Code review by Oracle: no issues.

Screenshot shows the updated MCP card on /launches.

## Screenshots

![Comfy MCP card on /launches with EXPLORE button now linking to
/mcp](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9f315ccc2692129159ae31efab9464684ff2f6db3e144feae6dd52fd314c0b47/pr-images/1782765166237-5e83667b-8dc3-4182-9891-609385a1dae5.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-29 22:29:38 +00:00
AustinMroz
9dcab4ee96 Essentials Cleanup (#13183)
Address several followup comments from #12744
2026-06-29 22:15:54 +00:00
Benjamin Lu
dc29f30b02 Track theme setting changes via telemetry (#13142)
Fix Color Palette changes not getting tracked, requested by design team.

Capture theme changes as `app:setting_changed` telemetry. The only
existing hook lived in `SettingItem.vue`, which renders *visible*
settings; `Comfy.ColorPalette` is hidden and changed through bespoke
theme UI, so it was never tracked.

Open to opinions here, we can also remove the hook in SettingItem.vue,
and just make everything that was visible opt in.

Linear:
https://linear.app/comfyorg/issue/GTM-158/track-theme-usage-with-posthog-events
2026-06-29 22:05:05 +00:00
imick-io
fb3350ee0e feat(website): redesign Comfy MCP setup steps and add button variant (#13285)
## Summary

Reworks the Comfy MCP page's **"Set up Comfy MCP in three steps"**
section to match the new design, and adds a per-action button `variant`
option to `FeatureGrid01`.

The three steps are now:

| Step | Title | Action |
| --- | --- | --- |
| 1 | Copy the MCP URL | Copy field showing
`https://cloud.comfy.org/mcp` |
| 2 | Add the connector | Filled button **"COMFY CLOUD MCP DOCS" ↗** →
MCP docs |
| 3 | Connect and sign in | Filled button **"COMFY CLOUD SKILLS" ↗** →
comfy-skills repo |

## Changes

- **`FeatureGrid01.vue`** — add `variant?: 'default' | 'outline'` to the
link card action; button now uses `card.action.variant ?? 'outline'`
instead of a hardcoded outline, so callers can opt into the filled
style.
- **`config/routes.ts`** — add `mcpSkills` external link
(`https://github.com/Comfy-Org/comfy-skills`).
- **`i18n/translations.ts`** — refresh the `mcp.setup.*` copy (en +
zh-CN): new subtitle, reworded steps, new `step2.cta` / `step3.cta`,
drop the now-unused `step1.cta`.
- **`SetupSection.vue`** — re-map cards: step 1 → copy field, steps 2 &
3 → filled link buttons.

## Test plan

- [x] `pnpm typecheck` — 0 errors
- [x] Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck)
pass
- [ ] Visual check on `/mcp` and `/zh-CN/mcp` (copy field on step 1; two
filled yellow CTAs with up-right arrows on steps 2 & 3)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:08:03 +00:00
imick-io
be8e0010ee feat(website): rebuild Comfy MCP page on the design system (+ zh-CN) (#13283)
Rebuilds the **Comfy MCP** marketing page on the website design-system
stack and adds the missing zh-CN page.

## What's here
- Replaces the bespoke `components/product/mcp/` section silo with thin
`templates/mcp/*` wrappers over reusable `blocks/` + `common/`
components.
- Adds `src/pages/zh-CN/mcp.astro` and threads `locale` through every
section (was English-only).
- New/extended design-system blocks:
- `FeatureGrid01` — setup steps, with a reusable `ui/CopyableField`
(uses `@vueuse/core` `useClipboard`).
- `FeatureGrid02` — how-it-works steps with `NodeUnionIcon` connectors +
a CTA pair via `ui/button`.
- `FeatureRows01` — alternating media rows; `ReasonsSplit01` — "why"
list.
- `HeroSplit01` gained `subtitle`, a `media` slot, and a `class`
passthrough; `SectionHeader` gained `align`.
- Standardized block section spacing on `px-6 py-16 lg:py-24`.
- Refreshed all 8 MCP FAQ answers (en + zh-CN) and hydrated the FAQ
section so the accordion is interactive.

## Notes
- Stacked on the original MCP landing-page commits (previously PR
#13095); those ride along here.
- `typecheck` and `build` are green; `/mcp` and `/zh-CN/mcp` both render
in both locales.

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

---------

Co-authored-by: Balpreet Brar <balpreet.brar@growthnatives.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-29 10:04:24 -07:00
imick-io
d0e97d6933 fix(website): move launches nav item and add cleanplate workflow link (#13282)
## Summary
- Move the `/launches` nav item from **Company → More** to **Products →
Features** in the main navbar
- Add the workflow link to the **Cleanplate Walkthrough** learning
tutorial (`https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/`)

## Changes
- `apps/website/src/data/mainNavigation.ts`
- `apps/website/src/data/learningTutorials.ts`

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:24:10 +00:00
110 changed files with 4595 additions and 856 deletions

View File

@@ -1,5 +1,6 @@
import { fileURLToPath } from 'node:url'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -153,6 +154,76 @@ test.describe('Product showcase accordion @interaction', () => {
})
})
test.describe('Hero image picker @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
// Exclude the glitch swap's outgoing image, which lingers in the DOM until
// the transition settles, so the locator always resolves to the current one.
const activeImage = (page: Page) =>
page.locator(
'[data-testid="hero-active-image"]:visible:not(.hero-glitch-leave-active)'
)
const outputImage = (page: Page) =>
page.locator(
'[data-testid="hero-output-image"]:visible:not(.hero-glitch-leave-active)'
)
test('defaults to the portrait variant with its thumbnail selected', async ({
page
}) => {
await expect(activeImage(page)).toHaveAttribute(
'src',
/input-portrait\.png/
)
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-cyberpunk\.png/
)
await expect(
page.getByRole('button', { name: /portrait/i })
).toHaveAttribute('aria-pressed', 'true')
})
test('hovering a thumbnail swaps the large image and moves selection', async ({
page
}) => {
const deerThumb = page.getByRole('button', { name: /deer/i })
await deerThumb.hover()
await expect(activeImage(page)).toHaveAttribute('src', /input-deer\.png/)
await expect(deerThumb).toHaveAttribute('aria-pressed', 'true')
await expect(
page.getByRole('button', { name: /portrait/i })
).toHaveAttribute('aria-pressed', 'false')
})
test('selecting an input cascades the matching output', async ({ page }) => {
await page.getByRole('button', { name: /vase/i }).click()
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-vase\.png/
)
await page.getByRole('button', { name: /deer/i }).click()
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-deer\.png/
)
})
test('thumbnails are keyboard operable', async ({ page }) => {
const mirrorThumb = page.getByRole('button', { name: /mirror/i })
await mirrorThumb.focus()
await page.keyboard.press('Enter')
await expect(activeImage(page)).toHaveAttribute('src', /input-mirror\.png/)
await expect(mirrorThumb).toHaveAttribute('aria-pressed', 'true')
})
})
test.describe('Video player @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.route(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -34,7 +34,6 @@
"lenis": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

View File

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

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -26,7 +26,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

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

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import SectionHeader from '../common/SectionHeader.vue'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Cta = { label: string; href: string; target?: '_blank' }
export interface FeatureStep {
id: string
number: string
title: string
description: string
}
defineProps<{
heading: string
steps: readonly FeatureStep[]
primaryCta?: Cta
secondaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader>{{ heading }}</SectionHeader>
<!-- Step cards in a row, joined by node-union connectors on desktop -->
<div
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
>
<template v-for="(step, i) in steps" :key="step.id">
<div
v-if="i > 0"
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
aria-hidden="true"
>
<NodeUnionIcon
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
/>
</div>
<div
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
>
<div class="flex flex-1 flex-col gap-4 p-8">
<div>
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ step.number }}
</p>
<h3
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
>
{{ step.title }}
</h3>
</div>
<p class="text-primary-comfy-canvas">
{{ step.description }}
</p>
</div>
</div>
</template>
</div>
<div
v-if="primaryCta || secondaryCta"
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<Button
v-if="primaryCta"
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
variant="outline"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import GlassCard from '../common/GlassCard.vue'
import SectionHeader from '../common/SectionHeader.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
// <video> has no native alt; used as the player's accessible label.
alt?: string
poster?: string
tracks?: readonly VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}
export interface FeatureRow {
id: string
title: string
description: string
media: RowMedia
}
const {
heading,
eyebrow,
locale = 'en',
rows
} = defineProps<{
heading: string
eyebrow?: string
locale?: Locale
rows: readonly FeatureRow[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" max-width="xl">
{{ heading }}
</SectionHeader>
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
<GlassCard
v-for="(row, i) in rows"
:key="row.id"
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
>
<!-- Text -->
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ row.title }}
</h3>
<p class="text-sm text-smoke-700 lg:text-base">
{{ row.description }}
</p>
</div>
<!-- Media: image or video -->
<div
:class="
cn(
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
>
<img
v-if="row.media.type === 'image'"
:src="row.media.src"
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
:locale="locale"
:aria-label="row.media.alt ?? row.title"
:src="row.media.src"
:poster="row.media.poster"
:tracks="row.media.tracks"
:autoplay="row.media.autoplay"
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
/>
</div>
</GlassCard>
</div>
</section>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
@@ -27,6 +29,7 @@ const {
badgeLogoAlt,
title,
titleHighlight,
subtitle,
features = [],
primaryCta,
secondaryCta,
@@ -41,14 +44,17 @@ const {
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false
videoHideControls = false,
class: className
} = defineProps<{
locale?: Locale
class?: HTMLAttributes['class']
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
subtitle?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
@@ -72,7 +78,8 @@ const {
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
className
)
"
>
@@ -84,7 +91,7 @@ const {
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
@@ -93,6 +100,13 @@ const {
<template v-else>{{ title }}</template>
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
>
{{ subtitle }}
</p>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
@@ -127,27 +141,29 @@ const {
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
<slot name="media">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</slot>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
export interface Reason {
id: string
title: string
description: string
}
const { highlightClass = 'text-white' } = defineProps<{
heading: string
headingHighlight?: string
highlightClass?: string
subtitle?: string
reasons: readonly Reason[]
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
>
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
>
<h2
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
>
{{ heading
}}<span v-if="headingHighlight" :class="highlightClass">{{
headingHighlight
}}</span>
</h2>
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
{{ subtitle }}
</p>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="reason in reasons"
:key="reason.id"
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
>
<div class="shrink-0 xl:w-84">
<h3
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ reason.title }}
</h3>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -7,12 +7,14 @@ const {
label,
headingTag = 'h2',
maxWidth = 'lg',
headingSize = 'section'
headingSize = 'section',
align = 'center'
} = defineProps<{
label?: string
headingTag?: 'h1' | 'h2' | 'h3'
maxWidth?: 'md' | 'lg' | 'xl'
headingSize?: 'section' | 'hero'
align?: 'center' | 'start'
}>()
const maxWidthClass = {
@@ -28,7 +30,14 @@ const headingSizeClass = {
</script>
<template>
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
<div
:class="
cn(
maxWidthClass[maxWidth],
align === 'center' ? 'mx-auto text-center' : 'text-left'
)
"
>
<SectionLabel v-if="label">{{ label }}</SectionLabel>
<component
:is="headingTag"

View File

@@ -37,7 +37,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp }
]
},
{

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { colorPresets, colorSwatches } from './useHeroControls'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { controls, locale = 'en' } = defineProps<{
controls: HeroControls
locale?: Locale
}>()
const { colorPresetId, swatchId, colorIntensity } = controls
const remixLabel = computed(
() => `${t('hero.color.remix', locale)} ${colorIntensity.value}%`
)
</script>
<template>
<div class="flex flex-col gap-2.5">
<div
class="flex gap-1.5"
role="group"
:aria-label="t('hero.color.palette', locale)"
>
<button
v-for="s in colorSwatches"
:key="s.id"
type="button"
:aria-pressed="s.id === swatchId"
:aria-label="t(s.labelKey, locale)"
:style="{ backgroundColor: `rgb(${s.rgb})` }"
:class="
cn(
'size-5 rounded-full ring-1 ring-white/20 transition-transform ring-inset',
s.id === swatchId
? 'scale-110 ring-2 ring-white/80'
: 'opacity-75 hover:opacity-100'
)
"
@pointerdown.stop
@click.stop="swatchId = s.id"
/>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
v-for="p in colorPresets"
:key="p.id"
type="button"
:aria-pressed="p.id === colorPresetId"
:class="
cn(
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
p.id === colorPresetId
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
)
"
@pointerdown.stop
@click.stop="colorPresetId = p.id"
>
{{ t(p.labelKey, locale) }}
</button>
</div>
<label class="flex flex-col gap-1">
<span
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
>
{{ remixLabel }}
</span>
<input
v-model.number="colorIntensity"
type="range"
min="0"
max="100"
:aria-label="remixLabel"
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
@pointerdown.stop
@click.stop
/>
</label>
</div>
</template>

View File

@@ -0,0 +1,440 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import HeroColorNode from './HeroColorNode.vue'
import HeroGraphNode from './HeroGraphNode.vue'
import HeroHeadline from './HeroHeadline.vue'
import HeroImagePicker from './HeroImagePicker.vue'
import HeroLightingNode from './HeroLightingNode.vue'
import HeroOutputFrame from './HeroOutputFrame.vue'
import { imageVariants, textureImage } from './heroGraphData'
import { computeWires } from './heroGraphWires'
import type { NodeId, Point, Rect } from './heroGraphWires'
import { useHeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const controls = useHeroControls()
const { activeNode } = controls
const activeId = ref<string>(imageVariants[0].id)
const activeVariant = computed(
() => imageVariants.find((v) => v.id === activeId.value) ?? imageVariants[0]
)
// The desktop graph is authored in a fixed design coordinate space and scaled
// as a single unit to fit the viewport width, so wires and the OUTPUT bleed are
// preserved on every screen. Node positions are live state so they can be
// dragged; widths are fixed per node and heights are measured once for wiring.
const STAGE_W = 1600
const STAGE_H = 780
const MAX_SCALE = 1.3
const NODE_W: Record<NodeId, number> = {
image: 300,
texture: 200,
color: 210,
lighting: 210,
output: 760
}
// Whole graph is nudged left of the stage centre so the OUTPUT node bleeds less
// far off the right edge.
const positions = ref<Record<NodeId, Point>>({
image: { x: 16, y: 28 },
texture: { x: 52, y: 512 },
color: { x: 404, y: 446 },
lighting: { x: 662, y: 446 },
output: { x: 956, y: 110 }
})
const frameRef = ref<HTMLElement>()
const stageRef = ref<HTMLElement>()
const scale = ref(1)
const heights = ref<Record<string, number>>({})
// Heights are read from layout offsets (not getBoundingClientRect) so they stay
// in unscaled design coordinates regardless of the stage's scale transform.
function measureHeights() {
const stage = stageRef.value
if (!stage) return
const next: Record<string, number> = {}
stage.querySelectorAll<HTMLElement>('[data-node]').forEach((el) => {
next[el.dataset.node ?? ''] = el.offsetHeight
})
heights.value = next
}
function updateScale() {
const width = frameRef.value?.clientWidth ?? STAGE_W
scale.value = Math.min(width / STAGE_W, MAX_SCALE)
}
function refresh() {
updateScale()
measureHeights()
}
useResizeObserver(frameRef, refresh)
const stageStyle = computed(() => ({
width: `${STAGE_W}px`,
height: `${STAGE_H}px`,
transform: `translateX(-50%) scale(${scale.value})`
}))
function nodeStyle(id: NodeId) {
return {
left: `${positions.value[id].x}px`,
top: `${positions.value[id].y}px`,
width: `${NODE_W[id]}px`
}
}
// Wires recompute from live positions + measured heights, so they track the
// nodes synchronously while dragging with no measure round-trip.
const anchors = computed<Record<NodeId, Rect>>(() => {
const ids = Object.keys(positions.value) as NodeId[]
return Object.fromEntries(
ids.map((id) => [
id,
{ ...positions.value[id], w: NODE_W[id], h: heights.value[id] ?? 0 }
])
) as Record<NodeId, Rect>
})
const dragging = ref<NodeId | null>(null)
let drag = { id: '' as NodeId, pointerId: -1, px: 0, py: 0, ox: 0, oy: 0 }
function onPointerDown(id: NodeId, e: PointerEvent) {
if (e.button !== 0) return
drag = {
id,
pointerId: e.pointerId,
px: e.clientX,
py: e.clientY,
ox: positions.value[id].x,
oy: positions.value[id].y
}
dragging.value = id
}
// A small threshold keeps taps on the image picker from registering as drags.
function onPointerMove(e: PointerEvent) {
if (dragging.value == null || e.pointerId !== drag.pointerId) return
const dx = e.clientX - drag.px
const dy = e.clientY - drag.py
if (Math.hypot(dx, dy) < 4) return
positions.value[drag.id] = {
x: drag.ox + dx / scale.value,
y: drag.oy + dy / scale.value
}
}
function onPointerUp() {
dragging.value = null
}
// Listeners live on window so a drag continues even when the pointer outruns the
// node; registered in onMounted to keep window off the SSR path.
onMounted(() => {
void nextTick(refresh)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
})
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
})
const wires = computed(() => computeWires(anchors.value))
const dots = computed<{ p: Point; accent: boolean }[]>(() =>
wires.value.flatMap((w) => [
{ p: w.from, accent: w.accent },
{ p: w.to, accent: w.accent }
])
)
</script>
<template>
<div class="relative w-full">
<!-- Desktop / large screens: a fixed design stage scaled to fit the width -->
<div
ref="frameRef"
class="relative hidden aspect-1600/780 max-h-[1000px] w-full lg:block"
>
<div
ref="stageRef"
class="absolute top-0 left-1/2 origin-top"
:style="stageStyle"
>
<svg
class="pointer-events-none absolute inset-0 size-full overflow-visible"
:viewBox="`0 0 ${STAGE_W} ${STAGE_H}`"
fill="none"
aria-hidden="true"
>
<path
v-for="(wire, i) in wires"
:key="i"
:d="wire.d"
:stroke="
wire.accent
? 'var(--color-primary-comfy-yellow)'
: 'rgba(255,255,255,0.16)'
"
:stroke-width="wire.accent ? 2 : 1.5"
stroke-linecap="round"
/>
<circle
v-for="(dot, i) in dots"
:key="`d${i}`"
:cx="dot.p.x"
:cy="dot.p.y"
:r="dot.accent ? 4 : 3"
:fill="
dot.accent
? 'var(--color-primary-comfy-yellow)'
: 'rgba(255,255,255,0.3)'
"
/>
<!-- Energy pulses that flow toward the OUTPUT while a control node is
engaged; idle-hidden via opacity, animated through CSS. -->
<g :class="cn(activeNode && 'hero-wire-active')">
<path
v-for="(wire, i) in wires"
:key="`p${i}`"
:d="wire.d"
class="hero-wire-pulse"
stroke="var(--color-primary-comfy-yellow)"
stroke-width="2.5"
stroke-linecap="round"
pathLength="1"
stroke-dasharray="0.18 0.82"
/>
</g>
</svg>
<div class="absolute top-[150px] left-[636px] z-20 -translate-x-1/2">
<HeroHeadline :locale />
</div>
<div
data-node="image"
:class="
cn(
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
dragging === 'image' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('image')"
@pointerdown="onPointerDown('image', $event)"
>
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
<HeroImagePicker
:variants="imageVariants"
:active-id="activeId"
:locale
@select="(id) => (activeId = id)"
/>
</HeroGraphNode>
</div>
<div
data-node="texture"
:class="
cn(
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
dragging === 'texture' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('texture')"
@pointerdown="onPointerDown('texture', $event)"
>
<HeroGraphNode :label="t('hero.node.texture', locale)" accent>
<div class="aspect-square w-full overflow-hidden rounded-xl">
<img
:src="textureImage.src"
:alt="t(textureImage.altKey, locale)"
draggable="false"
class="size-full object-cover"
/>
</div>
</HeroGraphNode>
</div>
<div
data-node="color"
:class="
cn(
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
dragging === 'color' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('color')"
@pointerdown="onPointerDown('color', $event)"
@pointerenter="activeNode = 'color'"
@pointerleave="activeNode = null"
>
<HeroGraphNode
:label="t('hero.node.color', locale)"
:active="activeNode === 'color'"
>
<HeroColorNode :controls :locale />
</HeroGraphNode>
</div>
<div
data-node="lighting"
:class="
cn(
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
dragging === 'lighting' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('lighting')"
@pointerdown="onPointerDown('lighting', $event)"
@pointerenter="activeNode = 'lighting'"
@pointerleave="activeNode = null"
>
<HeroGraphNode
:label="t('hero.node.lighting', locale)"
:active="activeNode === 'lighting'"
>
<HeroLightingNode :controls :locale />
</HeroGraphNode>
</div>
<div
data-node="output"
:class="
cn(
'absolute cursor-grab touch-none select-none active:cursor-grabbing',
dragging === 'output' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('output')"
@pointerdown="onPointerDown('output', $event)"
>
<HeroGraphNode :label="t('hero.node.output', locale)">
<HeroOutputFrame
:controls
:variant="activeVariant"
:locale
class="h-[560px]"
/>
</HeroGraphNode>
</div>
</div>
</div>
<!-- Mobile / tablet: a compact connected graph that fits one screen the
IMAGE selector forks into COLOR + LIGHTING, which merge into the live
OUTPUT. Connectors are decorative SVGs aligned to the 2-column grid. -->
<div class="flex flex-col items-center px-5 pt-3 pb-8 lg:hidden">
<HeroHeadline :locale compact />
<div class="mt-3 w-full max-w-sm sm:max-w-md">
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
<HeroImagePicker
:variants="imageVariants"
:active-id="activeId"
:locale
hide-preview
thumb-class="h-14"
@select="(id) => (activeId = id)"
/>
</HeroGraphNode>
<div class="relative h-6 w-full" aria-hidden="true">
<svg
class="absolute inset-0 size-full"
viewBox="0 0 100 36"
preserveAspectRatio="none"
fill="none"
>
<path
d="M50 3 C 50 22 25 14 25 34"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
<path
d="M50 3 C 50 22 75 14 75 34"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<span
class="bg-primary-comfy-yellow absolute top-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
/>
<span
class="absolute bottom-0 left-1/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="absolute bottom-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
</div>
<div class="grid grid-cols-2 items-stretch gap-2">
<HeroGraphNode :label="t('hero.node.color', locale)">
<HeroColorNode :controls :locale />
</HeroGraphNode>
<HeroGraphNode :label="t('hero.node.lighting', locale)">
<HeroLightingNode :controls :locale />
</HeroGraphNode>
</div>
<div class="relative h-6 w-full" aria-hidden="true">
<svg
class="absolute inset-0 size-full"
viewBox="0 0 100 36"
preserveAspectRatio="none"
fill="none"
>
<path
d="M25 2 C 25 18 50 14 50 33"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
<path
d="M75 2 C 75 18 50 14 50 33"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<span
class="absolute top-0 left-1/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="absolute top-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="bg-primary-comfy-yellow absolute bottom-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
/>
</div>
<HeroGraphNode :label="t('hero.node.output', locale)" accent>
<HeroOutputFrame
:controls
:variant="activeVariant"
:locale
class="h-[150px]"
/>
</HeroGraphNode>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const {
label,
accent = false,
active = false,
class: customClass = ''
} = defineProps<{
label: string
accent?: boolean
active?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'bg-transparency-ink-t80 rounded-2xl border p-3 backdrop-blur-sm transition-shadow duration-500 ease-out hover:shadow-[0_0_45px_-15px_rgb(242_255_89/0.35)]',
accent ? 'border-primary-comfy-yellow' : 'border-white/10',
active &&
'border-primary-comfy-yellow/70 shadow-[0_0_45px_-10px_rgb(242_255_89/0.5)]',
customClass
)
"
>
<span
class="text-primary-warm-gray block pl-1 text-[0.7rem] font-medium tracking-[0.22em] uppercase"
>
{{ label }}
</span>
<div class="mt-2">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en', compact = false } = defineProps<{
locale?: Locale
compact?: boolean
}>()
const lines = computed(() => t('hero.title', locale).split('\n'))
const size = computed(() => (compact ? 'text-3xl sm:text-4xl' : 'text-5xl'))
// Desktop splits the two lines apart so the liquid link reads as a bridge
// travelling between them; mobile keeps them tightly stacked (no link there).
const lineGap = computed(() => (compact ? '-mt-2' : 'mt-4'))
const pill =
'inline-block rounded-2xl px-5 py-2 font-formula-narrow leading-none font-semibold uppercase'
// PP Formula Narrow sits high in its em box; nudge the glyphs down so they read
// optically centered inside the highlighter block.
const inner = 'relative top-[0.06em] inline-block'
</script>
<template>
<div class="flex flex-col items-center text-center">
<div class="inline-grid">
<!-- Liquid yellow backing: the two pills merge through the goo filter
(defined once in HeroSection). Text is transparent here, present only
so each pill sizes to its line. -->
<div
class="relative col-start-1 row-start-1 flex flex-col items-center"
style="filter: url(#hero-goo)"
aria-hidden="true"
>
<span
:class="cn(pill, size, 'bg-primary-comfy-yellow text-transparent')"
>
{{ lines[0] }}
</span>
<span
:class="
cn(pill, size, 'bg-primary-comfy-yellow text-transparent', lineGap)
"
>
{{ lines[1] }}
</span>
<span
v-if="!compact"
class="hero-liquid-link bg-primary-comfy-yellow pointer-events-none absolute top-1/2 left-1/2 h-9 w-5 rounded-full"
/>
</div>
<!-- Crisp dark text on top of the liquid backing -->
<h1 class="col-start-1 row-start-1 flex flex-col items-center">
<span :class="cn(pill, size, 'text-primary-comfy-ink')">
<span :class="inner">{{ lines[0] }}</span>
</span>
<span :class="cn(pill, size, 'text-primary-comfy-ink', lineGap)">
<span :class="inner">{{ lines[1] }}</span>
</span>
</h1>
</div>
<p
:class="
cn(
'max-w-md text-primary-comfy-canvas',
compact ? 'mt-5 hidden text-sm/relaxed sm:block' : 'mt-8 text-base'
)
"
>
{{ t('hero.subtitle', locale) }}
</p>
</div>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { ImageVariant } from './heroGraphData'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
variants,
activeId,
locale = 'en',
previewSrc,
previewAlt,
previewTestId = 'hero-active-image',
hint,
hidePreview = false,
thumbClass = 'aspect-square'
} = defineProps<{
variants: readonly ImageVariant[]
activeId: string
locale?: Locale
// Override the large preview with the result the active input produces, so the
// mobile card can lead with the OUTPUT while the thumbnails stay the selector.
previewSrc?: string
previewAlt?: string
previewTestId?: string
hint?: string
// Render only the thumbnail strip, e.g. beneath a standalone output frame.
hidePreview?: boolean
// Sizing for each thumbnail; defaults to square, overridable for tight rows.
thumbClass?: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const active = computed(
() => variants.find((v) => v.id === activeId) ?? variants[0]
)
const preview = computed(() => ({
src: previewSrc ?? active.value.src,
alt: previewAlt ?? t(active.value.altKey, locale)
}))
// Hover (mouse) and focus (keyboard) both swap the active variant. Click stays
// as the activation path for touch, where there is no hover.
function selectOnHover(id: string, e: PointerEvent) {
if (e.pointerType === 'mouse') emit('select', id)
}
</script>
<template>
<div>
<div
v-if="!hidePreview"
class="relative aspect-square w-full overflow-hidden rounded-xl"
>
<Transition name="hero-glitch">
<img
:key="preview.src"
:src="preview.src"
:alt="preview.alt"
:data-testid="previewTestId"
draggable="false"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
</div>
<p v-if="hint" class="text-primary-warm-gray pl-1 text-xs/relaxed">
{{ hint }}
</p>
<div
class="mt-2 flex gap-2"
role="group"
:aria-label="t('hero.image.pickerLabel', locale)"
>
<button
v-for="variant in variants"
:key="variant.id"
type="button"
:aria-pressed="variant.id === activeId"
:aria-label="t(variant.altKey, locale)"
:class="
cn(
'focus-visible:outline-primary-comfy-yellow relative flex-1 overflow-hidden rounded-md transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2',
thumbClass,
variant.id === activeId
? 'ring-primary-comfy-yellow opacity-100 ring-2'
: 'opacity-50 hover:opacity-90'
)
"
@pointerenter="selectOnHover(variant.id, $event)"
@focus="emit('select', variant.id)"
@click="emit('select', variant.id)"
>
<img
:src="variant.src"
alt=""
aria-hidden="true"
draggable="false"
class="size-full object-cover"
/>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { lightModes } from './useHeroControls'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { controls, locale = 'en' } = defineProps<{
controls: HeroControls
locale?: Locale
}>()
const { lightModeId, lightIntensity, lightDir, setLightFromUnit } = controls
const intensityLabel = computed(
() => `${t('hero.light.intensity', locale)} ${lightIntensity.value}%`
)
const dotStyle = computed(() => ({
left: `${lightDir.value.x * 100}%`,
top: `${lightDir.value.y * 100}%`
}))
const pad = ref<HTMLElement>()
const dragging = ref(false)
function applyFromEvent(e: PointerEvent) {
const el = pad.value
if (!el) return
const r = el.getBoundingClientRect()
setLightFromUnit(
(e.clientX - r.left) / r.width,
(e.clientY - r.top) / r.height
)
}
function onDown(e: PointerEvent) {
dragging.value = true
pad.value?.setPointerCapture(e.pointerId)
applyFromEvent(e)
}
function onMove(e: PointerEvent) {
if (dragging.value) applyFromEvent(e)
}
function onUp(e: PointerEvent) {
dragging.value = false
pad.value?.releasePointerCapture?.(e.pointerId)
}
</script>
<template>
<div class="flex flex-col gap-2.5">
<div class="flex items-center gap-3">
<div
ref="pad"
class="relative size-12 shrink-0 touch-none rounded-full bg-white/5 ring-1 ring-white/10 ring-inset"
:aria-label="t('hero.light.direction', locale)"
@pointerdown.stop.prevent="onDown"
@pointermove.stop="onMove"
@pointerup.stop="onUp"
@pointercancel.stop="onUp"
>
<div
class="hero-light-glow pointer-events-none absolute inset-0 rounded-full"
/>
<div
class="bg-primary-comfy-yellow pointer-events-none absolute size-3 -translate-1/2 rounded-full shadow-[0_0_10px_2px_rgb(242_255_89/0.6)]"
:style="dotStyle"
/>
</div>
<label class="flex flex-1 flex-col gap-1">
<span
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
>
{{ intensityLabel }}
</span>
<input
v-model.number="lightIntensity"
type="range"
min="0"
max="100"
:aria-label="intensityLabel"
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
@pointerdown.stop
@click.stop
/>
</label>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
v-for="m in lightModes"
:key="m.id"
type="button"
:aria-pressed="m.id === lightModeId"
:class="
cn(
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
m.id === lightModeId
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
)
"
@pointerdown.stop
@click.stop="lightModeId = m.id"
>
{{ t(m.labelKey, locale) }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { ImageVariant } from './heroGraphData'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
controls,
variant,
locale = 'en',
class: customClass = ''
} = defineProps<{
controls: HeroControls
variant: ImageVariant
locale?: Locale
class?: HTMLAttributes['class']
}>()
const {
activeNode,
reducedMotion,
pointer,
outputFilter,
colorLayerStyle,
lightLayerStyle,
lightMode
} = controls
const metaText = computed(
() =>
`${t('hero.output.grade', locale)} · ${t('hero.output.colorActive', locale)} · ${t(lightMode.value.labelKey, locale)} ${t('hero.output.lightingSuffix', locale)}`
)
function onMove(e: PointerEvent) {
if (reducedMotion.value || e.pointerType !== 'mouse') return
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
pointer.value = {
x: (e.clientX - r.left) / r.width,
y: (e.clientY - r.top) / r.height
}
}
function onLeave() {
pointer.value = null
}
</script>
<template>
<div
:class="
cn(
'relative w-full overflow-hidden rounded-xl transition-shadow duration-500',
activeNode && 'hero-output-live',
customClass
)
"
@pointermove="onMove"
@pointerleave="onLeave"
>
<div class="relative size-full" :style="{ filter: outputFilter }">
<Transition name="hero-glitch">
<img
:key="variant.output.src"
:src="variant.output.src"
:alt="t(variant.output.altKey, locale)"
data-testid="hero-output-image"
draggable="false"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
</div>
<div
class="pointer-events-none absolute inset-0 transition-opacity duration-500"
:style="colorLayerStyle"
/>
<div
class="pointer-events-none absolute inset-0 mix-blend-screen transition-opacity duration-500"
:style="lightLayerStyle"
/>
<div
v-if="activeNode"
class="hero-output-sweep pointer-events-none absolute inset-0"
/>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-linear-to-t from-black/70 to-transparent px-3 pt-6 pb-2"
>
<span class="truncate font-mono text-[0.6rem] text-white/70">
{{ metaText }}
</span>
<span class="flex shrink-0 items-center gap-1">
<span
class="hero-live-dot bg-primary-comfy-yellow size-1.5 rounded-full"
/>
<span
class="text-[0.6rem] font-medium tracking-wide whitespace-nowrap text-white/70 uppercase"
>
{{ t('hero.output.live', locale) }}
</span>
</span>
</div>
</div>
</template>

View File

@@ -1,55 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue'
import HeroGraph from './HeroGraph.vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
/>
</div>
<section class="relative overflow-hidden bg-primary-comfy-ink">
<!-- Gooey filter that merges the headline's two highlighter blocks into a
single liquid shape. Referenced by HeroHeadline via filter: url(#). -->
<svg class="absolute size-0" aria-hidden="true">
<defs>
<filter id="hero-goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="b" />
<feColorMatrix
in="b"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -10"
/>
</filter>
</defs>
</svg>
<div class="flex-1 px-6 py-12 lg:px-16">
<h1
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
>
{{ t('hero.title', locale) }}
</h1>
<p
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
>
{{ t('hero.runFirstWorkflow', locale) }}
</BrandButton>
</div>
<HeroGraph :locale />
</section>
</template>

View File

@@ -0,0 +1,57 @@
import type { TranslationKey } from '../../i18n/translations'
interface NodeImage {
src: string
altKey: TranslationKey
}
export interface ImageVariant extends NodeImage {
id: string
// The result this input produces, shown in the OUTPUT node. Swapping the
// input cascades its matching output through the graph.
output: NodeImage
}
export const imageVariants = [
{
id: 'v1',
src: '/images/hero/input-portrait.png',
altKey: 'hero.image.variant1',
output: {
src: '/images/hero/output-cyberpunk.png',
altKey: 'hero.output.variant1'
}
},
{
id: 'v2',
src: '/images/hero/input-vase.png',
altKey: 'hero.image.variant2',
output: {
src: '/images/hero/output-vase.png',
altKey: 'hero.output.variant2'
}
},
{
id: 'v3',
src: '/images/hero/input-deer.png',
altKey: 'hero.image.variant3',
output: {
src: '/images/hero/output-deer.png',
altKey: 'hero.output.variant3'
}
},
{
id: 'v4',
src: '/images/hero/input-mirror.png',
altKey: 'hero.image.variant4',
output: {
src: '/images/hero/output-mirror.png',
altKey: 'hero.output.variant4'
}
}
] as const satisfies readonly ImageVariant[]
export const textureImage: NodeImage = {
src: '/images/hero/input-vase.png',
altKey: 'hero.image.texture'
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NodeId, Rect } from './heroGraphWires'
import { computeWires, connections, spline } from './heroGraphWires'
// Cubic command shape: "M sx sy C c1x c1y c2x c2y ex ey"
function controlPoints(d: string) {
const [sx, sy, c1x, c1y, c2x, c2y, ex, ey] = d
.replace(/[MC]/g, ' ')
.trim()
.split(/\s+/)
.map(Number)
return { sx, sy, c1x, c1y, c2x, c2y, ex, ey }
}
describe('spline', () => {
it('departs and arrives horizontally for side ports even when the vertical gap dominates', () => {
// A short rightward run with a large vertical rise: must still leave/enter
// along x so it reads left-to-right, not as a top-down drop.
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
spline({ x: 0, y: 200 }, { x: 120, y: 0 }, 'h')
)
expect(c1y).toBe(sy)
expect(c2y).toBe(ey)
expect(c1x).toBeGreaterThan(sx)
expect(c2x).toBeLessThan(ex)
})
it('departs and arrives vertically for stacked ports', () => {
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
spline({ x: 100, y: 0 }, { x: 140, y: 400 }, 'v')
)
expect(c1x).toBe(sx)
expect(c2x).toBe(ex)
expect(c1y).toBeGreaterThan(sy)
expect(c2y).toBeLessThan(ey)
})
})
describe('connections', () => {
it('feeds the color remixer from texture, not directly from image', () => {
const pairs = connections.map((c) => `${c.from}->${c.to}`)
expect(pairs).toContain('texture->color')
expect(pairs).not.toContain('image->color')
})
it('chains image → texture → color → lighting → output', () => {
expect(connections.map((c) => `${c.from}->${c.to}`)).toEqual([
'image->texture',
'texture->color',
'color->lighting',
'lighting->output'
])
})
})
describe('computeWires', () => {
const anchors: Record<NodeId, Rect> = {
image: { x: 60, y: 28, w: 300, h: 395 },
texture: { x: 96, y: 500, w: 200, h: 225 },
color: { x: 470, y: 470, w: 150, h: 180 },
lighting: { x: 720, y: 500, w: 168, h: 179 },
output: { x: 1000, y: 110, w: 760, h: 611 }
}
it('only emits wires for nodes that have been measured', () => {
expect(computeWires({ image: anchors.image }).length).toBe(0)
expect(computeWires(anchors).length).toBe(connections.length)
})
it('marks the image → texture wire as the accent wire', () => {
const wires = computeWires(anchors)
expect(wires[0].accent).toBe(true)
expect(wires.slice(1).every((w) => !w.accent)).toBe(true)
})
})

View File

@@ -0,0 +1,110 @@
export type NodeId = 'image' | 'texture' | 'color' | 'lighting' | 'output'
export interface Point {
x: number
y: number
}
export interface Rect extends Point {
w: number
h: number
}
export interface Wire {
d: string
from: Point
to: Point
accent: boolean
}
type Axis = 'h' | 'v'
type Port = (r: Rect) => Point
const rightPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w, y: r.y + r.h * f })
const leftPort =
(f = 0.5): Port =>
(r) => ({ x: r.x, y: r.y + r.h * f })
const bottomPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w * f, y: r.y + r.h })
const topPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w * f, y: r.y })
function clampOffset(d: number): number {
return Math.min(Math.max(Math.abs(d) * 0.5, 55), 120)
}
// Soft cubic whose tangents follow the connected ports: side ports (right→left)
// depart horizontally, stacked ports (bottom→top) depart vertically. Keeping the
// tangent on the port axis stops a wire between side ports from reading as a
// top-down drop when the vertical gap happens to exceed the horizontal one.
export function spline(s: Point, e: Point, axis: Axis): string {
if (axis === 'h') {
const off = Math.sign(e.x - s.x || 1) * clampOffset(e.x - s.x)
return `M ${s.x} ${s.y} C ${s.x + off} ${s.y} ${e.x - off} ${e.y} ${e.x} ${e.y}`
}
const off = Math.sign(e.y - s.y || 1) * clampOffset(e.y - s.y)
return `M ${s.x} ${s.y} C ${s.x} ${s.y + off} ${e.x} ${e.y - off} ${e.x} ${e.y}`
}
interface Connection {
from: NodeId
to: NodeId
fromPort: Port
toPort: Port
axis: Axis
accent?: boolean
}
export const connections: Connection[] = [
{
from: 'image',
to: 'texture',
fromPort: bottomPort(0.4),
toPort: topPort(0.5),
axis: 'v',
accent: true
},
{
from: 'texture',
to: 'color',
fromPort: rightPort(0.5),
toPort: leftPort(0.5),
axis: 'h'
},
{
from: 'color',
to: 'lighting',
fromPort: rightPort(0.5),
toPort: leftPort(0.45),
axis: 'h'
},
{
from: 'lighting',
to: 'output',
fromPort: rightPort(0.4),
toPort: leftPort(0.4),
axis: 'h'
}
]
export function computeWires(anchors: Partial<Record<NodeId, Rect>>): Wire[] {
return connections.flatMap((c) => {
const fr = anchors[c.from]
const to = anchors[c.to]
if (!fr || !to) return []
const from = c.fromPort(fr)
const dest = c.toPort(to)
return [
{
from,
to: dest,
accent: c.accent ?? false,
d: spline(from, dest, c.axis)
}
]
})
}

View File

@@ -0,0 +1,222 @@
import { usePreferredReducedMotion } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, ref } from 'vue'
import type { CSSProperties } from 'vue'
import type { Point } from './heroGraphWires'
import type { TranslationKey } from '../../i18n/translations'
type ColorPresetId = 'cyberpunk' | 'film' | 'dream' | 'editorial'
type LightModeId = 'soft' | 'rim' | 'neon' | 'studio'
type HeroNodeId = 'color' | 'lighting'
interface ColorPreset {
id: ColorPresetId
labelKey: TranslationKey
saturate: number
contrast: number
hue: number
blend: CSSProperties['mixBlendMode']
}
interface ColorSwatch {
id: string
labelKey: TranslationKey
// Space-separated RGB channels, ready for `rgb(<rgb> / <alpha>)`.
rgb: string
}
interface LightMode {
id: LightModeId
labelKey: TranslationKey
spread: number
core: number
rim: number
tint: string
}
export const colorPresets: ColorPreset[] = [
{
id: 'cyberpunk',
labelKey: 'hero.color.preset.cyberpunk',
saturate: 1.3,
contrast: 1.1,
hue: 8,
blend: 'overlay'
},
{
id: 'film',
labelKey: 'hero.color.preset.film',
saturate: 0.9,
contrast: 1.2,
hue: -6,
blend: 'overlay'
},
{
id: 'dream',
labelKey: 'hero.color.preset.dream',
saturate: 1.2,
contrast: 0.95,
hue: 22,
blend: 'soft-light'
},
{
id: 'editorial',
labelKey: 'hero.color.preset.editorial',
saturate: 0.78,
contrast: 1.08,
hue: 0,
blend: 'overlay'
}
]
export const colorSwatches: ColorSwatch[] = [
{ id: 'magenta', labelKey: 'hero.color.swatch.magenta', rgb: '255 0 140' },
{ id: 'cyan', labelKey: 'hero.color.swatch.cyan', rgb: '0 209 255' },
{ id: 'lime', labelKey: 'hero.color.swatch.lime', rgb: '170 255 90' },
{ id: 'amber', labelKey: 'hero.color.swatch.amber', rgb: '255 176 74' },
{ id: 'violet', labelKey: 'hero.color.swatch.violet', rgb: '150 110 255' }
]
export const lightModes: LightMode[] = [
{
id: 'soft',
labelKey: 'hero.light.mode.soft',
spread: 72,
core: 0.4,
rim: 0,
tint: '255 244 224'
},
{
id: 'rim',
labelKey: 'hero.light.mode.rim',
spread: 56,
core: 0.3,
rim: 0.55,
tint: '176 214 255'
},
{
id: 'neon',
labelKey: 'hero.light.mode.neon',
spread: 62,
core: 0.5,
rim: 0.3,
tint: '206 120 255'
},
{
id: 'studio',
labelKey: 'hero.light.mode.studio',
spread: 88,
core: 0.62,
rim: 0,
tint: '255 255 255'
}
]
// Centralizes the hero's interactive grade so the desktop graph and the mobile
// column drive the same OUTPUT overlays. Returns plain refs (mutated directly by
// the node controls) plus computed CSS the output frame binds inline.
export function useHeroControls() {
const motionPref = usePreferredReducedMotion()
const reducedMotion = computed(() => motionPref.value === 'reduce')
const colorPresetId = ref<ColorPresetId>('cyberpunk')
const swatchId = ref(colorSwatches[0].id)
const colorIntensity = ref(72)
const lightModeId = ref<LightModeId>('neon')
const lightIntensity = ref(58)
const lightDir = ref<Point>({ x: 0.64, y: 0.3 })
const activeNode = ref<HeroNodeId | null>(null)
// Cursor position over the output (0..1), letting the light drift toward the
// pointer on fine-pointer devices; null when absent or motion-reduced.
const pointer = ref<Point | null>(null)
const colorPreset = computed(
() =>
colorPresets.find((p) => p.id === colorPresetId.value) ?? colorPresets[0]
)
const swatch = computed(
() => colorSwatches.find((s) => s.id === swatchId.value) ?? colorSwatches[0]
)
const lightMode = computed(
() => lightModes.find((m) => m.id === lightModeId.value) ?? lightModes[0]
)
const outputFilter = computed(() => {
const c = colorIntensity.value / 100
const l = lightIntensity.value / 100
const p = colorPreset.value
const saturate = 1 + (p.saturate - 1) * c
const contrast = 1 + (p.contrast - 1) * c
const hue = p.hue * c
const brightness = 1 + l * (lightMode.value.core - 0.3) * 0.45
return `saturate(${saturate.toFixed(3)}) contrast(${contrast.toFixed(3)}) hue-rotate(${hue.toFixed(1)}deg) brightness(${brightness.toFixed(3)})`
})
const colorLayerStyle = computed<CSSProperties>(() => ({
backgroundImage: `linear-gradient(125deg, rgb(${swatch.value.rgb} / 0.55), rgb(${swatch.value.rgb} / 0.06) 72%)`,
mixBlendMode: colorPreset.value.blend,
opacity: (colorIntensity.value / 100) * 0.5
}))
const lightPos = computed<Point>(() => {
const base = lightDir.value
const p = pointer.value
if (!p || reducedMotion.value) return base
return {
x: base.x + (p.x - base.x) * 0.3,
y: base.y + (p.y - base.y) * 0.3
}
})
const lightLayerStyle = computed<CSSProperties>(() => {
const m = lightMode.value
const x = (lightPos.value.x * 100).toFixed(1)
const y = (lightPos.value.y * 100).toFixed(1)
const layers = [
`radial-gradient(circle at ${x}% ${y}%, rgb(${m.tint} / 0.9), transparent ${m.spread}%)`
]
if (m.rim > 0) {
layers.push(
`linear-gradient(105deg, transparent 58%, rgb(${m.tint} / ${m.rim}))`
)
}
return {
backgroundImage: layers.join(', '),
opacity: (lightIntensity.value / 100) * 0.72
}
})
function setLightFromUnit(x: number, y: number) {
const dx = x - 0.5
const dy = y - 0.5
const dist = Math.hypot(dx, dy)
const max = 0.5
const scaled = dist > max ? max / dist : 1
lightDir.value = {
x: clamp(0.5 + dx * scaled, 0, 1),
y: clamp(0.5 + dy * scaled, 0, 1)
}
}
return {
reducedMotion,
colorPresetId,
swatchId,
colorIntensity,
lightModeId,
lightIntensity,
lightDir,
lightMode,
activeNode,
pointer,
outputFilter,
colorLayerStyle,
lightLayerStyle,
setLightFromUnit
}
}
export type HeroControls = ReturnType<typeof useHeroControls>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
const {
value,
copyLabel = 'Copy',
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
void copy(value)
}
</script>
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />
</button>
</div>
</template>

View File

@@ -19,7 +19,8 @@ const baseRoutes = {
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models'
models: '/p/supported-models',
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -65,6 +66,8 @@ export const externalLinks = {
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',

View File

@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
},
cta: {
label: EXPLORE,
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{

View File

@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
label: 'English'
}
],
// href: '#',
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
tags: [partnerNodesTag, imageToVideoTag]
},
{

View File

@@ -69,10 +69,19 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
header: t('nav.colFeatures', locale),
items: [
{
label: t('nav.mcpServer', locale),
href: routes.mcp,
badge: 'new'
},
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,
@@ -180,11 +189,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -11,6 +11,16 @@ const translations = {
'zh-CN': '图像生成视频'
},
// UI (global, reusable across sections)
'ui.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'ui.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
@@ -39,6 +49,82 @@ const translations = {
en: 'Run your first workflow',
'zh-CN': '运行你的第一个工作流'
},
'hero.node.image': { en: 'IMAGE', 'zh-CN': '图像' },
'hero.node.texture': { en: 'TEXTURE', 'zh-CN': '纹理' },
'hero.node.lighting': { en: 'LIGHTING', 'zh-CN': '光照' },
'hero.node.color': { en: 'COLOR REMIXER', 'zh-CN': '颜色重映射' },
'hero.node.output': { en: 'OUTPUT', 'zh-CN': '输出' },
'hero.image.variant1': {
en: 'Portrait under multicolor studio lighting',
'zh-CN': '多彩影棚灯光下的人像'
},
'hero.image.variant2': {
en: 'Vase rendered as a rainbow normal map',
'zh-CN': '以彩虹法线贴图渲染的花瓶'
},
'hero.image.variant3': {
en: 'Deer in a forest with chromatic aberration',
'zh-CN': '带色散效果的森林中的鹿'
},
'hero.image.variant4': {
en: 'Two people at a mirror, glitch lighting',
'zh-CN': '镜前两人,故障风格灯光'
},
'hero.image.texture': {
en: 'Texture pass: rainbow normal-map vase',
'zh-CN': '纹理处理:彩虹法线贴图花瓶'
},
'hero.output.variant1': {
en: 'Output: cyberpunk portrait among glowing CRT screens',
'zh-CN': '输出:发光 CRT 屏幕中的赛博朋克人像'
},
'hero.output.variant2': {
en: 'Output: ceramic vase relit on a plinth in a dim library',
'zh-CN': '输出:昏暗图书馆中重新打光、置于基座上的陶瓷花瓶'
},
'hero.output.variant3': {
en: 'Output: iridescent deer before a neon city skyline',
'zh-CN': '输出:霓虹城市天际线前的虹彩鹿'
},
'hero.output.variant4': {
en: 'Output: couple at a cracked mirror in neon bathroom light',
'zh-CN': '输出:霓虹浴室灯光下裂镜前的情侣'
},
'hero.image.pickerLabel': {
en: 'Choose input image variant',
'zh-CN': '选择输入图像变体'
},
'hero.image.hint': {
en: 'Tap a source image to remix the result',
'zh-CN': '点按源图像以重新生成结果'
},
'hero.color.preset.cyberpunk': { en: 'Cyberpunk', 'zh-CN': '赛博朋克' },
'hero.color.preset.film': { en: 'Film', 'zh-CN': '胶片' },
'hero.color.preset.dream': { en: 'Dream', 'zh-CN': '梦境' },
'hero.color.preset.editorial': { en: 'Editorial', 'zh-CN': '杂志' },
'hero.color.remix': { en: 'Remix', 'zh-CN': '重混' },
'hero.color.palette': { en: 'Choose accent color', 'zh-CN': '选择强调色' },
'hero.color.swatch.magenta': { en: 'Magenta', 'zh-CN': '品红' },
'hero.color.swatch.cyan': { en: 'Cyan', 'zh-CN': '青色' },
'hero.color.swatch.lime': { en: 'Lime', 'zh-CN': '青柠' },
'hero.color.swatch.amber': { en: 'Amber', 'zh-CN': '琥珀' },
'hero.color.swatch.violet': { en: 'Violet', 'zh-CN': '紫罗兰' },
'hero.light.mode.soft': { en: 'Soft', 'zh-CN': '柔光' },
'hero.light.mode.rim': { en: 'Rim', 'zh-CN': '轮廓光' },
'hero.light.mode.neon': { en: 'Neon', 'zh-CN': '霓虹' },
'hero.light.mode.studio': { en: 'Studio', 'zh-CN': '影棚' },
'hero.light.intensity': { en: 'Light', 'zh-CN': '光强' },
'hero.light.direction': { en: 'Light direction', 'zh-CN': '光照方向' },
'hero.output.grade': {
en: 'cinematic-grade_v2',
'zh-CN': 'cinematic-grade_v2'
},
'hero.output.colorActive': {
en: 'color remap active',
'zh-CN': '色彩重映射已启用'
},
'hero.output.lightingSuffix': { en: 'lighting', 'zh-CN': '光照' },
'hero.output.live': { en: 'Live preview', 'zh-CN': '实时预览' },
// ProductShowcaseSection
'showcase.subtitle1': {
@@ -1825,6 +1911,311 @@ const translations = {
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
},
// MCP Meta
'mcp.meta.title': {
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.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
},
// MCP HeroSection
'mcp.hero.heading': {
en: 'Drive ComfyUI from\nany AI agent.',
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
},
'mcp.hero.subtitle': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
},
'mcp.hero.demoActionGenerateImage': {
en: 'GENERATE-IMAGE',
'zh-CN': '生成图像'
},
'mcp.hero.demoActionGenerate3d': {
en: 'GENERATE-3D ASSET',
'zh-CN': '生成 3D 资产'
},
'mcp.hero.demoActionUpscale': {
en: 'UPSCALE-IMAGE',
'zh-CN': '放大图像'
},
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'zh-CN': '添加连接器'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
'zh-CN': 'COMFY CLOUD MCP 文档'
},
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.step3.description': {
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
},
'mcp.setup.step3.cta': {
en: 'COMFY CLOUD SKILLS',
'zh-CN': 'COMFY CLOUD 技能'
},
// MCP WhyBuildSection
'mcp.why.heading': {
en: 'Why build on\n',
'zh-CN': '为什么选择\n'
},
'mcp.why.headingHighlight': {
en: 'Comfy MCP?',
'zh-CN': 'Comfy MCP'
},
'mcp.why.subtitle': {
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
},
'mcp.why.1.title': {
en: 'Open protocol,\nany client.',
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
'zh-CN': '完整引擎,\n非沙箱环境。'
},
'mcp.why.2.description': {
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
'zh-CN':
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
},
'mcp.why.3.title': {
en: 'Outputs you keep.',
'zh-CN': '输出归你所有。'
},
'mcp.why.3.description': {
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
'zh-CN':
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
},
'mcp.why.4.title': {
en: 'Powered by\nComfy Cloud.',
'zh-CN': '由 Comfy Cloud\n提供支持。'
},
'mcp.why.4.description': {
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
'zh-CN': '无需本地 GPU通过你团队信赖的相同基础设施运行。'
},
// MCP ToolsSection
'mcp.tools.heading': {
en: 'Everything ComfyUI can do,\nnow available as tools.',
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
},
'mcp.tools.1.title': {
en: 'Generate anything',
'zh-CN': '生成任意内容'
},
'mcp.tools.1.description': {
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
'zh-CN':
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
},
'mcp.tools.1.alt': {
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
},
'mcp.tools.2.title': {
en: 'Search the ecosystem',
'zh-CN': '搜索生态系统'
},
'mcp.tools.2.description': {
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
},
'mcp.tools.2.alt': {
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
},
'mcp.tools.3.title': {
en: 'Run real workflows',
'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.',
'zh-CN':
'将任何 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 HowItWorksSection
'mcp.howItWorks.heading': {
en: 'How it works',
'zh-CN': '工作原理'
},
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
'mcp.howItWorks.step1.title': {
en: 'CONNECT',
'zh-CN': '连接'
},
'mcp.howItWorks.step1.description': {
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
'zh-CN':
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop通过 OAuth 一次性登录。无需管理 API 密钥。'
},
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
'mcp.howItWorks.step2.title': {
en: 'DISCOVER',
'zh-CN': '发现'
},
'mcp.howItWorks.step2.description': {
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
'zh-CN':
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
},
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
'mcp.howItWorks.step3.title': {
en: 'CREATE',
'zh-CN': '创作'
},
'mcp.howItWorks.step3.description': {
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
},
// MCP FAQSection
'mcp.faq.heading': {
en: 'Q&As',
'zh-CN': '常见问答'
},
'mcp.faq.1.q': {
en: 'Which clients are supported?',
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'目前支持 Claude Code 和 Claude Desktop均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.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.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: "The sign-in didn't open a browser.",
'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.',
'zh-CN':
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接?'
},
'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授权。一步即可添加连接和斜杠命令。'
},
'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.',
'zh-CN':
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.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 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
'nav.products': { en: 'Products', 'zh-CN': '产品' },
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
@@ -1867,6 +2258,7 @@ const translations = {
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
@@ -2597,18 +2989,18 @@ const translations = {
'zh-CN': 'Plans; Fees; Free Tier.'
},
'tos.payment.block.1': {
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfys right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
'zh-CN':
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfys right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
},
'tos.payment.block.2.heading': {
en: 'Self-Serve Credit Card Billing.',
'zh-CN': 'Self-Serve Credit Card Billing.'
},
'tos.payment.block.3': {
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.',
'zh-CN':
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.'
},
'tos.payment.block.4.heading': {
en: 'Invoiced Billing.',

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
import HeroSection from '../templates/mcp/HeroSection.vue'
import SetupSection from '../templates/mcp/SetupSection.vue'
import WhySection from '../templates/mcp/WhySection.vue'
import ToolsSection from '../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../templates/mcp/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'en')}
description={t('mcp.meta.description', 'en')}
>
<HeroSection locale="en" client:load />
<SetupSection locale="en" client:visible />
<WhySection locale="en" />
<ToolsSection locale="en" />
<HowItWorksSection locale="en" />
<ProductCardsSection locale="en" label-key="products.labelProducts" />
<FAQSection client:visible locale="en" />
</BaseLayout>

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
import HeroSection from '../../templates/mcp/HeroSection.vue'
import SetupSection from '../../templates/mcp/SetupSection.vue'
import WhySection from '../../templates/mcp/WhySection.vue'
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../../templates/mcp/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'zh-CN')}
description={t('mcp.meta.description', 'zh-CN')}
>
<HeroSection locale="zh-CN" client:load />
<SetupSection locale="zh-CN" client:visible />
<WhySection locale="zh-CN" />
<ToolsSection locale="zh-CN" />
<HowItWorksSection locale="zh-CN" />
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
<FAQSection client:visible locale="zh-CN" />
</BaseLayout>

View File

@@ -162,6 +162,45 @@
animation: ripple-effect 4s linear infinite;
}
@keyframes cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@utility animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
.card-slide-enter-active {
transition:
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.4s ease;
}
.card-slide-enter-from {
transform: translateX(56px);
opacity: 0;
}
/* Existing cards slide down smoothly when a new card is prepended. */
.card-slide-move {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card-slide-leave-active {
transition: opacity 0.2s ease;
}
.card-slide-leave-to {
opacity: 0;
}
@utility animate-delay-* {
animation-delay: --value([*]);
}
@@ -176,6 +215,158 @@
opacity: 0;
}
/* Gentle chromatic crossfade used when hero node images swap. The incoming
frame eases in with a soft RGB split that resolves smoothly while the
outgoing frame fades beneath it — no positional jumps. Reduced-motion users
get a near-instant swap via the global override below. */
@keyframes hero-glitch-in {
0% {
opacity: 0;
filter: drop-shadow(7px 0 0 rgb(255 0 92 / 0.5))
drop-shadow(-7px 0 0 rgb(0 209 255 / 0.5));
}
60% {
opacity: 1;
filter: drop-shadow(3px 0 0 rgb(255 0 92 / 0.28))
drop-shadow(-3px 0 0 rgb(0 209 255 / 0.28));
}
100% {
opacity: 1;
filter: none;
}
}
.hero-glitch-enter-active,
.hero-glitch-leave-active {
position: absolute;
inset: 0;
}
.hero-glitch-enter-active {
animation: hero-glitch-in 0.55s ease-out both;
z-index: 1;
}
.hero-glitch-leave-active {
transition: opacity 0.55s ease;
}
.hero-glitch-leave-to {
opacity: 0;
}
/* The two headline lines are split apart with a gap; #hero-goo only bridges
them where this blob sits, so as the blob glides side to side it drags a
liquid link back and forth between the words. Travel is kept well inside the
narrower lower line (~±72px) so the bridge glides through the centre without
reaching either edge and poking out past a line. */
@keyframes hero-liquid-link {
0% {
transform: translate(calc(-50% - 64px), -50%) scale(1, 1.03);
}
50% {
transform: translate(calc(-50% + 64px), -50%) scale(1, 1.03);
}
100% {
transform: translate(calc(-50% - 64px), -50%) scale(1, 1.03);
}
}
/* Slow, mostly-horizontal glide keeps the liquid link premium and readable. */
.hero-liquid-link {
animation: hero-liquid-link 5s ease-in-out infinite;
}
/* Workflow connectors: a short bright dash flows from each upstream node toward
the OUTPUT while a control node is engaged. path-length is normalized to 1 so
the dash travels at a consistent rate regardless of wire length. */
.hero-wire-pulse {
stroke-dashoffset: 1;
opacity: 0;
transition: opacity 0.3s ease;
}
.hero-wire-active .hero-wire-pulse {
opacity: 0.9;
animation: hero-wire-flow 1.2s linear infinite;
}
@keyframes hero-wire-flow {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
/* OUTPUT frame responds to an engaged control node with a soft neon rim and a
slow specular sweep — premium, not flashy. */
.hero-output-live {
box-shadow:
0 0 0 1px rgb(242 255 89 / 0.35),
0 0 45px -8px rgb(242 255 89 / 0.45);
}
.hero-output-sweep::before {
content: '';
position: absolute;
inset: 0;
width: 45%;
background: linear-gradient(
100deg,
transparent,
rgb(255 255 255 / 0.16),
transparent
);
animation: hero-output-sweep 2.8s ease-in-out infinite;
}
@keyframes hero-output-sweep {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(160%);
}
}
@keyframes hero-live-pulse {
0%,
100% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.hero-live-dot {
animation: hero-live-pulse 1.8s ease-in-out infinite;
}
/* Soft breathing glow inside the LIGHTING direction pad. */
.hero-light-glow {
background: radial-gradient(
circle at 50% 40%,
rgb(242 255 89 / 0.25),
transparent 65%
);
animation: hero-light-breathe 3s ease-in-out infinite;
}
@keyframes hero-light-breathe {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { Check } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
const cards = [
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
}
] as const
const visibleCount = ref(0)
const displayedPrompt = ref('')
const promptDone = ref(false)
const displayedCards = computed(() =>
cards
.slice(0, visibleCount.value)
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
// Newest card first — it slides in right below the prompt box and pushes
// the rest down.
.reverse()
)
let timer: ReturnType<typeof setTimeout> | null = null
let active = false
function schedule(fn: () => void, ms: number) {
timer = setTimeout(() => {
if (active) fn()
}, ms)
}
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
schedule(onDone, 350)
}
}
schedule(step, 50)
}
function revealNextCard() {
if (visibleCount.value >= cards.length) {
// All done — pause then reset
schedule(() => {
visibleCount.value = 0
schedule(revealNextCard, 500)
}, 2500)
return
}
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})
}
onMounted(() => {
active = true
schedule(revealNextCard, 600)
})
onUnmounted(() => {
active = false
if (timer) clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
<!-- Prompt panel -->
<div
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
>
<p
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
>
{{ displayedPrompt
}}<span
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
:class="promptDone ? 'animate-cursor-blink' : ''"
/>
</p>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-white/10" />
<div
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
>
{{ generateLabel }}
</div>
</div>
</div>
<!-- Cards accumulate each slides in from the right after its prompt cycle -->
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
<TransitionGroup
name="card-slide"
tag="div"
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
>
<div
v-for="(card, i) in displayedCards"
:key="card.file"
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
>
<img
:src="card.thumb"
:alt="card.action"
class="size-13.5 shrink-0 rounded-[14px] object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
>
{{ card.action }}
</p>
<p
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
>
{{ card.file }}
</p>
</div>
<span
v-if="card.tag"
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
>
<span class="ppformula-text-center">
{{ card.tag }}
</span>
</span>
<Check
class="size-4 shrink-0 text-primary-comfy-canvas/60"
:stroke-width="1.5"
/>
</div>
</TransitionGroup>
<!-- Bottom fade so accumulating cards dissolve into the page background -->
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),
question: t(`mcp.faq.${n}.q`, locale),
answer: t(`mcp.faq.${n}.a`, locale)
}))
</script>
<template>
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import ComfyMcpDemo from './ComfyMcpDemo.vue'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
</script>
<template>
<HeroSplit01
:locale="locale"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>
<ComfyMcpDemo :locale="locale" />
</template>
</HeroSplit01>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
const stepNumbers = [1, 2, 3] as const
const steps: FeatureStep[] = stepNumbers.map((n) => ({
id: String(n),
number: t(`mcp.howItWorks.step${n}.number`, locale),
title: t(`mcp.howItWorks.step${n}.title`, locale),
description: t(`mcp.howItWorks.step${n}.description`, locale)
}))
</script>
<template>
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
}
},
{
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
}
]
</script>
<template>
<FeatureGrid01
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
}
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
},
altKey: 'mcp.tools.1.alt'
},
{
n: 2,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
},
altKey: 'mcp.tools.2.alt'
},
{
n: 3,
media: {
type: 'video',
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true
},
altKey: 'mcp.tools.3.alt'
}
]
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
const alt = altKey ? t(altKey, locale) : undefined
return {
id: String(n),
title: t(`mcp.tools.${n}.title`, locale),
description: t(`mcp.tools.${n}.description`, locale),
media: { ...media, alt }
}
})
</script>
<template>
<FeatureRows01
:locale="locale"
:heading="t('mcp.tools.heading', locale)"
:rows="rows"
/>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasonNumbers = [1, 2, 3, 4] as const
const reasons: Reason[] = reasonNumbers.map((n) => ({
id: String(n),
title: t(`mcp.why.${n}.title`, locale),
description: t(`mcp.why.${n}.description`, locale)
}))
</script>
<template>
<ReasonsSplit01
:heading="t('mcp.why.heading', locale)"
:heading-highlight="t('mcp.why.headingHighlight', locale)"
highlight-class="text-primary-comfy-yellow"
:subtitle="t('mcp.why.subtitle', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -0,0 +1,27 @@
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
export interface McpCta {
label: string
href: string
target?: '_blank'
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,3 +1,4 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -6,72 +7,370 @@ import { VideoPreview } from '@e2e/fixtures/components/VideoPreview'
import { assetPath } from '@e2e/fixtures/utils/paths'
const file1 = 'workflow.mp4' as const
const file2 = 'workflow.webm' as const
const file2 = 'video-preview-wide.webm' as const
const file3 = 'video-preview-square.webm' as const
const file4 = 'video-preview-portrait.webm' as const
const MIN_PREVIEW_FRAME_HEIGHT = 100
const CENTER_TOLERANCE_PX = 1
const videoShapeFixtures = [
[file2, 'landscape'],
[file3, 'square'],
[file4, 'portrait']
] as const
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
type ThumbnailShape = (typeof videoShapeFixtures)[number][1]
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
interface VideoPreviewLayout {
objectFit: string
objectPosition: string
wrapperHeight: number
wrapperWidth: number
wrapperX: number
wrapperY: number
videoBoxHeight: number
videoBoxWidth: number
videoIntrinsicHeight: number
videoIntrinsicWidth: number
videoX: number
videoY: number
}
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
async function readVideoPreviewLayout(
preview: Locator
): Promise<VideoPreviewLayout | null> {
return await preview.evaluate((previewElement) => {
const video = previewElement.querySelector('video')
const wrapper = video?.parentElement
if (!(video instanceof HTMLVideoElement) || !wrapper) return null
const wrapperRect = wrapper.getBoundingClientRect()
const videoRect = video.getBoundingClientRect()
return {
objectFit: getComputedStyle(video).objectFit,
objectPosition: getComputedStyle(video).objectPosition,
wrapperHeight: wrapperRect.height,
wrapperWidth: wrapperRect.width,
wrapperX: wrapperRect.x,
wrapperY: wrapperRect.y,
videoBoxHeight: videoRect.height,
videoBoxWidth: videoRect.width,
videoIntrinsicHeight: video.videoHeight,
videoIntrinsicWidth: video.videoWidth,
videoX: videoRect.x,
videoY: videoRect.y
}
})
}
await test.step('Upload a video file', async () => {
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file1}`))
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
await expect(loadVideoNode).toContainText(file1)
await expect(loadVideo.video).toBeVisible()
})
async function requireBoundingBox(locator: Locator, subject: string) {
const box = await locator.boundingBox()
if (!box) throw new Error(`${subject} should have a bounding box`)
await test.step('Update displayed video', async () => {
const initialSrc = await loadVideo.videoSrc()
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file2}`))
comfyFiles.deleteAfterTest({ filename: file2, type: 'input' })
await expect(loadVideoNode).toContainText(file2)
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
})
return box
}
await test.step('Display multiple videmus', async () => {
await expect(loadVideo.navigationDots).toBeHidden()
async function expectNodeBoxUnchanged(
locator: Locator,
before: { height: number; width: number },
subject: string
) {
const after = await requireBoundingBox(locator, subject)
expect(
Math.abs(after.width - before.width),
`${subject} should not change node width`
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(
Math.abs(after.height - before.height),
`${subject} should not change node height`
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
}
//forcibly display multiple video files at once
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(
(names) => {
graph!.nodes[0].images.splice(
0,
1,
...names.map((filename) => ({
type: 'input',
filename,
subfolder: ''
}))
function objectPositionFraction(value: string) {
if (value.endsWith('%')) return Number.parseFloat(value) / 100
switch (value) {
case 'left':
case 'top':
return 0
case 'center':
return 0.5
case 'right':
case 'bottom':
return 1
default:
throw new Error(`Unsupported object-position value: ${value}`)
}
}
function objectPositionFractions(objectPosition: string) {
const [x = '50%', y = '50%'] = objectPosition.split(/\s+/)
return {
x: objectPositionFraction(x),
y: objectPositionFraction(y)
}
}
function getPaintedVideoRect({
objectPosition,
videoBoxHeight,
videoBoxWidth,
videoIntrinsicHeight,
videoIntrinsicWidth,
videoX,
videoY
}: VideoPreviewLayout) {
const videoAspectRatio = videoIntrinsicWidth / videoIntrinsicHeight
const boxAspectRatio = videoBoxWidth / videoBoxHeight
const paintedWidth =
videoAspectRatio > boxAspectRatio
? videoBoxWidth
: videoBoxHeight * videoAspectRatio
const paintedHeight =
videoAspectRatio > boxAspectRatio
? videoBoxWidth / videoAspectRatio
: videoBoxHeight
const position = objectPositionFractions(objectPosition)
return {
height: paintedHeight,
width: paintedWidth,
x: videoX + (videoBoxWidth - paintedWidth) * position.x,
y: videoY + (videoBoxHeight - paintedHeight) * position.y
}
}
function expectAspectRatioMatchesShape(
aspectRatio: number,
shape: ThumbnailShape
) {
if (shape === 'landscape') {
expect(
aspectRatio,
'landscape fixture should be wider than tall'
).toBeGreaterThan(1)
return
}
if (shape === 'portrait') {
expect(
aspectRatio,
'portrait fixture should be taller than wide'
).toBeLessThan(1)
return
}
expect(
Math.abs(aspectRatio - 1),
'square fixture should have matching width and height'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX / 100)
}
async function expectCenteredVideoPreview(preview: Locator) {
await expect
.poll(async () => {
const layout = await readVideoPreviewLayout(preview)
return layout?.videoIntrinsicWidth ?? 0
})
.toBeGreaterThan(0)
const layout = await readVideoPreviewLayout(preview)
if (!layout) throw new Error('Video preview should render a video element')
expect(
layout.wrapperHeight,
'video preview should keep a usable minimum frame height'
).toBeGreaterThanOrEqual(MIN_PREVIEW_FRAME_HEIGHT - CENTER_TOLERANCE_PX)
expect(layout.videoBoxWidth).toBeGreaterThan(0)
expect(layout.videoBoxHeight).toBeGreaterThan(0)
expect(layout.objectFit).toBe('contain')
const objectPosition = objectPositionFractions(layout.objectPosition)
expect(objectPosition.x).toBe(0.5)
expect(objectPosition.y).toBe(0.5)
const wrapperCenterX = layout.wrapperX + layout.wrapperWidth / 2
const wrapperCenterY = layout.wrapperY + layout.wrapperHeight / 2
const paintedVideo = getPaintedVideoRect(layout)
const paintedVideoCenterX = paintedVideo.x + paintedVideo.width / 2
const paintedVideoCenterY = paintedVideo.y + paintedVideo.height / 2
expect(
Math.abs(paintedVideoCenterX - wrapperCenterX),
'painted video should be horizontally centered in the preview space'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(
Math.abs(paintedVideoCenterY - wrapperCenterY),
'painted video should be vertically centered in the preview space'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(layout.videoBoxWidth).toBeLessThanOrEqual(
layout.wrapperWidth + CENTER_TOLERANCE_PX
)
expect(layout.videoBoxHeight).toBeLessThanOrEqual(
layout.wrapperHeight + CENTER_TOLERANCE_PX
)
expect(paintedVideo.width).toBeLessThanOrEqual(
layout.wrapperWidth + CENTER_TOLERANCE_PX
)
expect(paintedVideo.height).toBeLessThanOrEqual(
layout.wrapperHeight + CENTER_TOLERANCE_PX
)
return layout
}
test.describe(
'VideoPreview',
{ tag: ['@vue-nodes', '@node', '@widget'] },
() => {
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
})
const loadVideoFixture =
await comfyPage.vueNodes.getFixtureByTitle('Load Video')
await test.step('Upload a video file', async () => {
await loadVideo.upload.setInputFiles(
assetPath(`workflowInMedia/${file1}`)
)
},
[file1, file2]
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
await expect(loadVideoNode).toContainText(file1)
await expect(loadVideo.video).toBeVisible()
await expect(loadVideo.navigationDots).toHaveCount(2)
await loadVideo.navigationDots.nth(0).click()
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
await loadVideo.navigationDots.nth(1).click()
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
})
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(layout.videoIntrinsicWidth).toBeGreaterThan(0)
})
await test.step('Can redownload uploaded file', async () => {
await loadVideo.video.hover()
await expect(loadVideo.download).toBeVisible()
await test.step('Update displayed video across thumbnail shapes', async () => {
for (const [filename, shape] of videoShapeFixtures) {
const initialSrc = await loadVideo.videoSrc()
const nodeBoxBeforeLoad = await requireBoundingBox(
loadVideoNode,
`Load Video node before loading ${filename}`
)
await loadVideo.upload.setInputFiles(assetPath(`video/${filename}`))
comfyFiles.deleteAfterTest({
filename,
type: 'input'
})
await expect(loadVideoNode).toContainText(filename)
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
const downloadPromise = comfyPage.page.waitForEvent('download')
await loadVideo.download.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe(file2)
})
})
const layout = await expectCenteredVideoPreview(loadVideo.preview)
await expectNodeBoxUnchanged(
loadVideoNode,
nodeBoxBeforeLoad,
`Load Video node after loading ${filename}`
)
const updatedVideoAspectRatio =
layout.videoIntrinsicWidth / layout.videoIntrinsicHeight
expectAspectRatioMatchesShape(updatedVideoAspectRatio, shape)
}
})
await test.step('Keep video centered after horizontal resize', async () => {
const nodeBox = await requireBoundingBox(
loadVideoNode,
'Load Video node before horizontal resize'
)
const initialLayout = await expectCenteredVideoPreview(
loadVideo.preview
)
await loadVideoFixture.resizeFromCorner('SE', 180, 0)
await comfyPage.nextFrame()
await expect
.poll(loadVideoFixture.pollWidth)
.toBeGreaterThan(nodeBox.width + 100)
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(
layout.wrapperWidth - initialLayout.wrapperWidth,
'video preview space should grow with a wider node'
).toBeGreaterThan(100)
expect(
Math.abs(layout.wrapperHeight - initialLayout.wrapperHeight),
'horizontal resize should not change the preview space height'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
})
await test.step('Keep video centered after vertical resize', async () => {
const nodeBox = await requireBoundingBox(
loadVideoNode,
'Load Video node before vertical resize'
)
const initialLayout = await expectCenteredVideoPreview(
loadVideo.preview
)
await loadVideoFixture.resizeFromCorner('SE', 0, 180)
await comfyPage.nextFrame()
await expect
.poll(loadVideoFixture.pollHeight)
.toBeGreaterThan(nodeBox.height + 100)
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(
layout.wrapperHeight - initialLayout.wrapperHeight,
'video preview space should grow with a taller node'
).toBeGreaterThan(100)
expect(
Math.abs(layout.wrapperWidth - initialLayout.wrapperWidth),
'vertical resize should not change the preview space width'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
})
await test.step('Display multiple videos', async () => {
await expect(loadVideo.navigationDots).toBeHidden()
try {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(
(names) => {
graph!.nodes[0].images.splice(
0,
1,
...names.map((filename) => ({
type: 'input',
filename,
subfolder: ''
}))
)
},
[file1, file2]
)
} finally {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.nextFrame()
}
await expect(loadVideo.navigationDots).toHaveCount(2)
await loadVideo.navigationDots.nth(0).press('Enter')
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
await loadVideo.navigationDots.nth(1).press('Enter')
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
})
await test.step('Can redownload uploaded file', async () => {
await loadVideo.video.hover()
await expect(loadVideo.download).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await loadVideo.download.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe(file2)
})
})
}
)

View File

@@ -344,16 +344,6 @@ export const zDynamicComboInputSpec = z.tuple([
})
])
export const zDynamicGroupInputSpec = z.tuple([
z.literal('COMFY_DYNAMICGROUP_V3'),
zBaseInputOptions.extend({
template: zComfyInputsSpec,
min: z.number().int().nonnegative().optional().default(0),
max: z.number().int().positive().max(100).optional().default(50),
group_name: z.string().optional()
})
])
export const zMatchTypeOptions = z.object({
...zBaseInputOptions.shape,
type: z.literal('COMFY_MATCHTYPE_V3'),

3
pnpm-lock.yaml generated
View File

@@ -983,9 +983,6 @@ importers:
reka-ui:
specifier: 'catalog:'
version: 2.5.0(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
three:
specifier: 'catalog:'
version: 0.184.0
vue:
specifier: 'catalog:'
version: 3.5.34(typescript@5.9.3)

View File

@@ -70,8 +70,6 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />

View File

@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
}
</script>
<template>
<DropdownMenu button-class="icon-[lucide--list-filter]">
<DropdownMenu>
<template #button>
<Button size="icon" :aria-label="$t('g.filter')">
<i class="icon-[lucide--list-filter]" />
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
>
<span
class="flex-1"
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
/>
<DropdownMenuItemIndicator class="size-4 shrink-0">
<i class="icon-[lucide--check]" />

View File

@@ -117,8 +117,8 @@
</template>
<script setup lang="ts">
import { mapValues } from 'es-toolkit'
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { mapValues } from 'es-toolkit'
import type { MenuItem } from 'primevue/menuitem'
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
import {

View File

@@ -79,7 +79,6 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
removable?: boolean
values?: unknown
}
/** Input specification from node definition */
@@ -207,8 +206,7 @@ function extractWidgetDisplayOptions(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only,
removable: widget.options.removable
read_only: widget.options.read_only
}
}

View File

@@ -12,12 +12,11 @@ export function resolveEssentialTileNodeDef(
): ComfyNodeDefImpl | undefined {
const name = tile.nodeName
if (!name) return undefined
const byName = nodeDefStore.allNodeDefsByName[name]
if (byName) return byName
const target = name.startsWith(BLUEPRINT_TYPE_PREFIX)
? name.slice(BLUEPRINT_TYPE_PREFIX.length)
: name
return nodeDefStore.nodeDefs.find((d) => d.display_name === target)
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
return nodeDefStore.allNodeDefsByName[name]
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
}
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {

101
src/config/comfyApi.test.ts Normal file
View File

@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (route: string) => `/api${route}`,
fetchApi: vi.fn()
}
}))
vi.stubGlobal('fetch', vi.fn())
describe('getComfyApiBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_api_base_url: '' }
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
})
describe('getComfyPlatformBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = {
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
}
expect(getComfyPlatformBaseUrl()).toBe(
'https://my-ephem-platform.example.com'
)
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_platform_base_url: '' }
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})
describe('compatibility with comfyui servers that predate the override keys', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('falls back to build-time defaults when /features omits the URL keys', async () => {
// An older comfyui server has /features but doesn't know about
// comfy_api_base_url / comfy_platform_base_url yet.
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
supports_preview_metadata: true,
max_upload_size: 104857600
})
} as Response)
await refreshRemoteConfig({ useAuth: false })
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})

View File

@@ -1,4 +1,3 @@
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
@@ -20,10 +19,6 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
STAGING_PLATFORM_BASE_URL)
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
@@ -32,10 +27,6 @@ export function getComfyApiBaseUrl(): string {
}
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',

View File

@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
async function loadFirebase(useProdConfig: boolean) {
vi.resetModules()
vi.stubGlobal('__USE_PROD_CONFIG__', useProdConfig)
const { remoteConfig } = await import('@/platform/remoteConfig/remoteConfig')
const { getFirebaseConfig } = await import('./firebase')
return { getFirebaseConfig, remoteConfig }
}
describe('getFirebaseConfig', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('honors a full server-provided firebase_config (cloud builds)', async () => {
const cloud = {
apiKey: 'cloud-key',
authDomain: 'cloud.example.com',
projectId: 'some-cloud-project',
storageBucket: 'cloud.appspot.com',
messagingSenderId: '1',
appId: '1:1:web:abc'
}
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { firebase_config: cloud }
expect(getFirebaseConfig()).toEqual(cloud)
})
it('uses the dev project when the server reports firebase_env "dev", even if the build-time fallback is prod', async () => {
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { firebase_env: 'dev' }
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
it('falls back to the build-time config when the server reports no firebase_env', async () => {
const prod = await loadFirebase(true)
prod.remoteConfig.value = {}
expect(prod.getFirebaseConfig().projectId).toBe('dreamboothy')
const dev = await loadFirebase(false)
dev.remoteConfig.value = {}
expect(dev.getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
})

View File

@@ -1,6 +1,5 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
@@ -28,15 +27,12 @@ const PROD_CONFIG: FirebaseOptions = {
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
/**
* Returns the Firebase configuration for the current environment.
* - Cloud builds use runtime configuration delivered via feature flags
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
* Firebase config for the current backend: the server's firebase_config (cloud builds),
* else the bundled DEV_CONFIG when the server reports a dev-tier backend, else the build-time default.
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
if (runtimeConfig) return runtimeConfig
if (remoteConfig.value.firebase_env === 'dev') return DEV_CONFIG
return BUILD_TIME_CONFIG
}

View File

@@ -1,9 +1,5 @@
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import {
zAutogrowOptions,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -12,7 +8,6 @@ const dynamicTypeResolvers: Record<
(inputSpec: InputSpecV2) => string[]
> = {
COMFY_AUTOGROW_V3: resolveAutogrowType,
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
COMFY_MATCHTYPE_V3: (input) =>
zMatchTypeOptions
.safeParse(input)
@@ -25,21 +20,6 @@ export function resolveInputType(input: InputSpecV2): string[] {
: input.type.split(',')
}
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
const template = parsed.data?.[1]?.template
if (!template) return []
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
template.required,
template.optional
]
return inputTypes.flatMap((inputType) =>
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
resolveInputType(transformInputSpecV1ToV2(v, { name }))
)
)
}
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}

View File

@@ -1,6 +1,6 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -47,22 +47,6 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
function addDynamicGroup(
node: LGraphNode,
template: object,
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
) {
const options: Record<string, unknown> = { template }
if (min !== undefined) options.min = min
if (max !== undefined) options.max = max
addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
name,
isOptional: false
})
)
}
function addAutogrow(node: LGraphNode, template: unknown) {
addNodeInput(
node,
@@ -303,85 +287,3 @@ describe('Autogrow', () => {
])
})
})
describe('Dynamic Groups', () => {
const stringTemplate = { required: { a: ['STRING', {}] } }
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
const widgetNamed = (node: LGraphNode, name: string) =>
node.widgets!.find((w) => w.name === name)!
test('renders min rows on creation', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
expect(inputNames(node)).toStrictEqual([])
})
test('add row appends a new row up to max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
expect(widgetNames(node)).toStrictEqual(['g'])
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
// At max, further adds are ignored.
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
})
test('controller disabled option set at max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 1 })
expect(widgetNamed(node, 'g').options?.disabled).toBe(false)
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNamed(node, 'g').options?.disabled).toBe(true)
})
test('remove row renumbers later rows', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
const state = (
node as Parameters<typeof widgetNamed>[0] & {
comfyDynamic: {
dynamicGroup: Record<
string,
{ addRow: () => void; removeRow: (r: number) => void }
>
}
}
).comfyDynamic.dynamicGroup['g']
state.addRow()
state.addRow()
state.addRow()
const row0Field = widgetNamed(node, 'g.0.a')
const row2Field = widgetNamed(node, 'g.2.a')
state.removeRow(1)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
// Row 0 is untouched; the former row 2 shifts down into row 1.
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
})
test('rows below min cannot be removed', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
const state = (
node as Parameters<typeof widgetNamed>[0] & {
comfyDynamic: {
dynamicGroup: Record<string, { removeRow: (r: number) => void }>
}
}
).comfyDynamic.dynamicGroup['g']
// Row 0 is at the min boundary — removing it is a no-op.
state.removeRow(0)
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
})
})

View File

@@ -13,13 +13,11 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
zAutogrowOptions,
zDynamicComboInputSpec,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -30,18 +28,6 @@ import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
type DynamicGroupState = {
min: number
max: number
groupName?: string
inputSpecs: InputSpecV2[]
addRow: () => void
removeRow: (row: number) => void
}
export type DynamicGroupNode = LGraphNode & {
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
}
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
@@ -228,217 +214,7 @@ function dynamicComboWidget(
return { widget, minWidth, minHeight }
}
function withComfyDynamicGroup(
node: LGraphNode
): asserts node is DynamicGroupNode {
if (node.comfyDynamic?.dynamicGroup) return
node.comfyDynamic ??= {}
node.comfyDynamic.dynamicGroup = {}
}
const fieldName = (group: string, row: number, field: string) =>
`${group}.${row}.${field}`
/** Rename a field that sits above the removed row, shifting its index down. */
function shiftedFieldName(
group: string,
name: string,
removedRow: number
): string | undefined {
const prefix = `${group}.`
if (!name.startsWith(prefix)) return undefined
const rest = name.slice(prefix.length)
const dot = rest.indexOf('.')
if (dot === -1) return undefined
const row = Number(rest.slice(0, dot))
if (!Number.isInteger(row) || row <= removedRow) return undefined
return fieldName(group, row - 1, rest.slice(dot + 1))
}
const isGroupField = (group: string, name: string) =>
name.startsWith(`${group}.`)
const belongsToRow = (group: string, name: string, row: number): boolean =>
name.startsWith(`${group}.${row}.`)
function countGroupRows(group: string, node: LGraphNode): number {
const rows = new Set<number>()
for (const w of node.widgets ?? []) {
if (!isGroupField(group, w.name)) continue
const rest = w.name.slice(group.length + 1)
const dot = rest.indexOf('.')
if (dot !== -1) {
const row = Number(rest.slice(0, dot))
if (Number.isInteger(row)) rows.add(row)
}
}
return rows.size
}
/** Build field widgets for a single row, returning them detached from the node. */
function createRow(
group: string,
row: number,
state: DynamicGroupState,
node: DynamicGroupNode
): IBaseWidget[] {
const { addNodeInput } = useLitegraphService()
const startLen = node.widgets!.length
for (const spec of state.inputSpecs)
addNodeInput(node, {
...spec,
name: fieldName(group, row, spec.name),
display_name: spec.display_name ?? spec.name,
hidden: true,
socketless: true
})
return node.widgets!.splice(startLen)
}
function insertRowAfterGroup(
group: string,
node: LGraphNode,
rowWidgets: IBaseWidget[]
): void {
const lastIdx = node.widgets!.findLastIndex(
(w) => w.name === group || isGroupField(group, w.name)
)
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
}
function syncController(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
const controller = node.widgets?.find((w) => w.name === group)
if (!state || !controller) return
controller.options ??= {}
controller.options.disabled = countGroupRows(group, node) >= state.max
// Route through setSize (not `size[1] = …`) so the layout store and the Vue
// node's min-height floor are updated; a direct buffer write bypasses the
// size setter and leaves the node unable to shrink after rows are removed.
node.setSize([node.size[0], node.computeSize()[1]])
}
function addRow(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const row = countGroupRows(group, node)
if (row >= state.max) return
insertRowAfterGroup(group, node, createRow(group, row, state, node))
syncController(group, node)
app.canvas?.setDirty(true, true)
}
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state || row < state.min) return
for (const w of remove(node.widgets!, (w) =>
belongsToRow(group, w.name, row)
))
w.onRemove?.()
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
for (const w of node.widgets ?? []) {
const shifted = shiftedFieldName(group, w.name, row)
if (shifted !== undefined) w.name = shifted
}
for (const inp of node.inputs) {
const shifted = shiftedFieldName(group, inp.name, row)
if (shifted === undefined) continue
inp.name = shifted
if (inp.widget) inp.widget.name = shifted
}
syncController(group, node)
app.canvas?.setDirty(true, true)
}
/** Rebuild the group from scratch to hold exactly `count` rows. */
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const isRowMember = (name: string) => isGroupField(group, name)
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
w.onRemove?.()
remove(node.inputs, (inp) => isRowMember(inp.name))
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
const rowWidgets: IBaseWidget[] = []
for (let row = 0; row < count; row++)
rowWidgets.push(...createRow(group, row, state, node))
node.widgets.splice(insertAt, 0, ...rowWidgets)
}
function dynamicGroupWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
_appArg: ComfyApp
) {
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
const [, { template, min, max, group_name: groupName }] = parseResult.data
const toSpecs = (
inputs: Record<string, InputSpec> | undefined,
isOptional: boolean
) =>
Object.entries(inputs ?? {}).map(([name, spec]) =>
transformInputSpecV1ToV2(spec, { name, isOptional })
)
const inputSpecs = [
...toSpecs(template.required, false),
...toSpecs(template.optional, true)
]
withComfyDynamicGroup(node)
const typedNode = node as DynamicGroupNode
typedNode.comfyDynamic.dynamicGroup[inputName] = {
min,
max,
groupName,
inputSpecs,
addRow: () => addRow(inputName, typedNode),
removeRow: (row: number) => removeRow(inputName, row, typedNode)
}
node.widgets ??= []
const controller = node.addCustomWidget({
name: inputName,
type: 'dynamic_group',
value: min,
y: 0,
serialize: true,
callback: () => addRow(inputName, typedNode),
options: { socketless: true, disabled: false, min, max }
})
Object.defineProperty(controller, 'value', {
get() {
return countGroupRows(inputName, typedNode)
},
set(count: unknown) {
if (typeof count !== 'number') return
rebuildRows(inputName, count, typedNode)
syncController(inputName, typedNode)
},
configurable: true
})
controller.value = min
return { widget: controller }
}
export const dynamicWidgets = {
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
const dynamicInputs: Record<
string,
(node: LGraphNode, inputSpec: InputSpecV2) => void

View File

@@ -815,8 +815,10 @@ export class GroupNodeConfig {
* `configure`. The load-time migration unpacks each instance via
* {@link convertToNodes} and {@link LGraph.convertToSubgraph} repackages the
* result as a subgraph.
*
* @knipIgnoreUnusedButUsedByCustomNodes
*/
class GroupNodeHandler {
export class GroupNodeHandler {
node: LGraphNode
groupData: GroupNodeConfig

View File

@@ -75,7 +75,6 @@ export interface IWidgetOptions<TValues = unknown> {
// Vue widget options
disabled?: boolean
removable?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean

View File

@@ -2248,12 +2248,6 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"dynamicGroup": {
"addGroup": "Add {group_name}",
"removeGroup": "Remove {group_name}",
"group": "{group_name} #{index}",
"defaultGroupName": "Group"
},
"oauth": {
"consent": {
"allow": "Continue",

View File

@@ -32,13 +32,12 @@ import { i18n } from './i18n'
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
if (requiresRemoteConfigBootstrap) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
}
// Load remote config before initializeApp() below, so getFirebaseConfig() resolves
// against the server's runtime values instead of the build-time defaults.
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')

View File

@@ -0,0 +1,117 @@
import { createPinia, setActivePinia } from 'pinia'
import { markRaw, reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
function createTestDialogInstance(
key: string,
overrides: Partial<DialogInstance> = {}
): DialogInstance {
return {
key,
visible: true,
component: markRaw({ template: '<div />' }),
contentProps: {},
dialogComponentProps: {},
priority: 0,
...overrides
}
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => {
const dialogStack = reactive<DialogInstance[]>([])
return {
useDialogStore: () => ({ dialogStack })
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: null
}
}))
describe('keybindingService - dialog gate', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
const commandStore = useCommandStore()
mockCommandExecute = vi.fn()
commandStore.execute = mockCommandExecute
const dialogStore = useDialogStore()
dialogStore.dialogStack.length = 0
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
function createKeyboardEvent(
key: string,
target: HTMLElement = document.body
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [target])
return event
}
it('executes a global keybinding when no dialog is open', async () => {
const event = createKeyboardEvent('w')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith(
'Workspace.ToggleSidebarTab.workflows'
)
})
it('does NOT execute a global keybinding while a dialog is open', async () => {
const dialogStore = useDialogStore()
dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog'))
const event = createKeyboardEvent('w')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('still executes a keybinding whose target lives inside the open dialog', async () => {
const dialogStore = useDialogStore()
dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog'))
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const inner = document.createElement('button')
dialog.appendChild(inner)
document.body.appendChild(dialog)
try {
const event = createKeyboardEvent('w', inner)
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith(
'Workspace.ToggleSidebarTab.workflows'
)
} finally {
document.body.removeChild(dialog)
}
})
})

View File

@@ -108,7 +108,7 @@ describe('keybindingService - Escape key handling', () => {
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should execute Escape keybinding with modifiers regardless of dialog state', async () => {
it('should NOT execute Escape keybinding with modifiers when a dialog is open', async () => {
const dialogStore = useDialogStore()
dialogStore.dialogStack.push(createTestDialogInstance('test-dialog'))
@@ -125,7 +125,7 @@ describe('keybindingService - Escape key handling', () => {
const event = createKeyboardEvent('Escape', { ctrlKey: true })
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith('Test.CtrlEscape')
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should verify Escape keybinding exists in CORE_KEYBINDINGS', () => {

View File

@@ -55,6 +55,18 @@ export function useKeybindingService() {
}
}
/**
* Block global keybindings from triggering background actions while a
* modal dialog is open. Keybindings whose event target lives inside an
* open dialog still fire, so dialog-scoped shortcuts keep working.
*/
if (dialogStore.dialogStack.length > 0) {
const inDialog = target.closest?.('[role="dialog"]') != null
if (!inDialog) {
return
}
}
event.preventDefault()
const runCommandIds = new Set([
'Comfy.QueuePrompt',

View File

@@ -7,7 +7,8 @@ import { remoteConfig } from './remoteConfig'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `/ComfyUI/api${route}`)
}
}))
@@ -43,9 +44,10 @@ describe('refreshRemoteConfig', () => {
await refreshRemoteConfig({ useAuth: true })
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
cache: 'no-store'
})
expect(api.fetchApi).toHaveBeenCalledWith(
'/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(global.fetch).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
@@ -59,23 +61,56 @@ describe('refreshRemoteConfig', () => {
expect(api.fetchApi).toHaveBeenCalled()
expect(global.fetch).not.toHaveBeenCalled()
})
it('does not pass an abort signal on the authed branch (so it is never aborted)', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: true })
const init = vi.mocked(api.fetchApi).mock.calls[0][1]
expect(init?.signal).toBeUndefined()
})
})
describe('without auth', () => {
it('uses raw fetch when useAuth is false', async () => {
it('builds the no-auth url via api.apiURL so a path prefix is respected', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
cache: 'no-store'
})
expect(api.apiURL).toHaveBeenCalledWith('/features')
expect(global.fetch).toHaveBeenCalledWith(
'/ComfyUI/api/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(api.fetchApi).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
})
})
describe('timeout', () => {
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
const init = vi.mocked(global.fetch).mock.calls[0][1]
expect(init?.signal).toBeInstanceOf(AbortSignal)
})
it('falls back to empty config when the request aborts', async () => {
vi.mocked(global.fetch).mockRejectedValue(
new DOMException('Aborted', 'AbortError')
)
await refreshRemoteConfig({ useAuth: false })
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
})
describe('error handling', () => {
it('clears config on 401 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(

View File

@@ -4,6 +4,11 @@ import {
remoteConfigState
} from './remoteConfig'
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
// A same-origin GET against the local comfyui server should resolve in well under a second;
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
const FEATURES_FETCH_TIMEOUT_MS = 5_000
interface RefreshRemoteConfigOptions {
/**
* Whether to use authenticated API (default: true).
@@ -12,10 +17,14 @@ interface RefreshRemoteConfigOptions {
useAuth?: boolean
}
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
async function fetchRemoteConfig(
useAuth: boolean,
signal?: AbortSignal
): Promise<Response> {
const { api } = await import('@/scripts/api')
if (!useAuth) {
return fetch(api.apiURL('/features'), { cache: 'no-store', signal })
}
return api.fetchApi('/features', { cache: 'no-store' })
}
@@ -33,8 +42,13 @@ export async function refreshRemoteConfig(
): Promise<void> {
const { useAuth = true } = options
const controller = useAuth ? null : new AbortController()
const timeoutId = controller
? setTimeout(() => controller.abort(), FEATURES_FETCH_TIMEOUT_MS)
: null
try {
const response = await fetchRemoteConfig(useAuth)
const response = await fetchRemoteConfig(useAuth, controller?.signal)
if (response.ok) {
const config = await response.json()
@@ -59,5 +73,7 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
} finally {
if (timeoutId !== null) clearTimeout(timeoutId)
}
}

View File

@@ -93,6 +93,7 @@ export type RemoteConfig = {
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
firebase_env?: 'dev'
telemetry_disabled_events?: TelemetryEventName[]
enable_telemetry?: boolean
model_upload_button_enabled?: boolean

View File

@@ -9,13 +9,6 @@ import { i18n } from '@/i18n'
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
const trackSettingChanged = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
const mockGet = vi.fn()
const mockSet = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
@@ -40,7 +33,7 @@ const FormItemUpdateStub = defineComponent({
template: '<div data-testid="form-item-stub" />'
})
describe('SettingItem (telemetry UI tracking)', () => {
describe('SettingItem', () => {
beforeEach(() => {
vi.clearAllMocks()
emitFormValue = null
@@ -61,15 +54,15 @@ describe('SettingItem (telemetry UI tracking)', () => {
})
}
it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
it('persists setting updates through the setting store', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
name: 'Visible Setting',
type: 'text',
defaultValue: 'default'
}
mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
mockGet.mockReturnValue('default')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
@@ -78,33 +71,6 @@ describe('SettingItem (telemetry UI tracking)', () => {
await flushPromises()
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith(
expect.objectContaining({
setting_id: 'main.sub.setting.name',
previous_value: 'default',
new_value: 'normalized'
})
)
})
it('does not track telemetry when normalized value does not change', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
type: 'text',
defaultValue: 'same'
}
mockGet.mockReturnValueOnce('same').mockReturnValueOnce('same')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
emitFormValue!('same')
await flushPromises()
expect(trackSettingChanged).not.toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith('main.sub.setting.name', 'newvalue')
})
})

View File

@@ -31,7 +31,6 @@ import FormItem from '@/components/common/FormItem.vue'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingOption, SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { Settings } from '@/schemas/apiSchema'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -81,19 +80,6 @@ const settingValue = computed(() => settingStore.get(props.setting.id))
const updateSettingValue = async <K extends keyof Settings>(
newValue: Settings[K]
) => {
const telemetry = useTelemetry()
const settingId = props.setting.id
const previousValue = settingValue.value
await settingStore.set(settingId, newValue)
const normalizedValue = settingStore.get(settingId)
if (previousValue !== normalizedValue) {
telemetry?.trackSettingChanged({
setting_id: settingId,
previous_value: previousValue,
new_value: normalizedValue
})
}
await settingStore.set(props.setting.id, newValue)
}
</script>

View File

@@ -945,6 +945,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7',
telemetry: { trackChanges: true, includeValues: true },
migrateDeprecatedValue(val: unknown) {
const value = val as string
// Legacy custom palettes were prefixed with 'custom_'

View File

@@ -11,6 +11,16 @@ import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
const { trackSettingChanged } = vi.hoisted(() => ({
trackSettingChanged: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
// Mock the api
vi.mock('@/scripts/api', () => ({
api: {
@@ -399,11 +409,6 @@ describe('useSettingStore', () => {
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
expect(api.storeSetting).toHaveBeenCalledWith('test.setting', 'newvalue')
// Set the same value again, it should not trigger onChange
await store.set('test.setting', 'newvalue')
expect(onChangeMock).toHaveBeenCalledTimes(2)
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
// Set a different value, it should trigger onChange
await store.set('test.setting', 'differentvalue')
expect(onChangeMock).toHaveBeenCalledWith('differentvalue', 'newvalue')
@@ -415,6 +420,120 @@ describe('useSettingStore', () => {
)
})
it('tracks visible settings with values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting',
previous_value: 'default',
new_value: 'newvalue'
})
})
it('does not track hidden settings by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('does not track visible settings that opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('tracks visible settings without values when values opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { includeValues: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
})
it('tracks hidden settings that opt in, without shipping values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
// Setting the same value again is a no-op and should not re-emit
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
})
it('ships previous/new values when the setting opts into includeValues', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
await store.set('Comfy.ColorPalette', 'light')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('does not track telemetry when persistence fails', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
vi.mocked(api.storeSetting).mockRejectedValueOnce(new Error('failed'))
await expect(store.set('test.setting', 'newvalue')).rejects.toThrow(
'failed'
)
expect(trackSettingChanged).not.toHaveBeenCalled()
})
describe('object mutation prevention', () => {
beforeEach(() => {
const setting: SettingParams = {
@@ -542,6 +661,34 @@ describe('useSettingStore', () => {
expect(api.storeSetting).not.toHaveBeenCalled()
})
it('tracks only the settings in a batch that opt in', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
store.addSetting({
id: 'Comfy.Release.Version',
name: 'Release Version',
type: 'hidden',
defaultValue: ''
})
await store.setMany({
'Comfy.ColorPalette': 'light',
'Comfy.Release.Version': '1.0.0'
})
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('should skip unchanged values', async () => {
store.addSetting({
id: 'Comfy.Release.Version',
@@ -581,6 +728,7 @@ describe('useSettingStore', () => {
await store.setMany({ 'Comfy.Release.Version': 'existing' })
expect(api.storeSettings).not.toHaveBeenCalled()
expect(trackSettingChanged).not.toHaveBeenCalled()
})
})
})

View File

@@ -6,6 +6,8 @@ import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SettingChangedMetadata } from '@/platform/telemetry/types'
import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -23,6 +25,11 @@ export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
interface AppliedSetting<TValue> {
previousValue: TValue
newValue: TValue
}
function tryMigrateDeprecatedValue(
setting: SettingParams | undefined,
value: unknown
@@ -45,6 +52,28 @@ function onChange(
}
}
function settingChangedEvent<K extends keyof Settings>(
setting: SettingParams | undefined,
key: K,
applied: AppliedSetting<Settings[K]>
): SettingChangedMetadata | undefined {
if (!setting) return undefined
const telemetry = setting.telemetry
const isVisible = setting.type !== 'hidden'
const trackChanges = telemetry?.trackChanges ?? isVisible
if (!trackChanges) return undefined
const includeValues = telemetry?.includeValues ?? isVisible
return includeValues
? {
setting_id: key,
previous_value: applied.previousValue,
new_value: applied.newValue
}
: { setting_id: key }
}
export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Partial<Settings>>({})
const settingsById = ref<Record<string, SettingParams>>({})
@@ -99,7 +128,7 @@ export const useSettingStore = defineStore('setting', () => {
function applySettingLocally<K extends keyof Settings>(
key: K,
value: Settings[K]
): Settings[K] | undefined {
): AppliedSetting<Settings[K]> | undefined {
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
@@ -109,8 +138,12 @@ export const useSettingStore = defineStore('setting', () => {
if (newValue === oldValue) return undefined
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
return newValue as Settings[K]
const typedNewValue = newValue as Settings[K]
settingValues.value[key] = typedNewValue
return {
previousValue: oldValue,
newValue: typedNewValue
}
}
/**
@@ -121,7 +154,10 @@ export const useSettingStore = defineStore('setting', () => {
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
const applied = applySettingLocally(key, value)
if (applied === undefined) return
await api.storeSetting(key, applied)
await api.storeSetting(key, applied.newValue)
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) useTelemetry()?.trackSettingChanged(event)
}
/**
@@ -130,6 +166,7 @@ export const useSettingStore = defineStore('setting', () => {
*/
async function setMany(settings: Partial<Settings>) {
const updatedSettings: Partial<Settings> = {}
const telemetryEvents: SettingChangedMetadata[] = []
for (const key of Object.keys(settings) as (keyof Settings)[]) {
const applied = applySettingLocally(
@@ -137,12 +174,18 @@ export const useSettingStore = defineStore('setting', () => {
settings[key] as Settings[typeof key]
)
if (applied !== undefined) {
updatedSettings[key] = applied
updatedSettings[key] = applied.newValue
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) telemetryEvents.push(event)
}
}
if (Object.keys(updatedSettings).length > 0) {
await api.storeSettings(updatedSettings)
const telemetry = useTelemetry()
for (const event of telemetryEvents) {
telemetry?.trackSettingChanged(event)
}
}
}

View File

@@ -26,11 +26,22 @@ export interface SettingOption {
value?: string | number
}
type SettingTelemetryOptions =
| {
trackChanges: false
includeValues?: never
}
| {
trackChanges?: true
includeValues?: boolean
}
export interface SettingParams<TValue = unknown> extends FormItem {
id: keyof Settings
defaultValue: TValue | (() => TValue)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
onChange?(newValue: TValue, oldValue?: TValue): void
telemetry?: SettingTelemetryOptions
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.

View File

@@ -109,7 +109,7 @@ describe('PostHogTelemetryProvider', () => {
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageview: 'history_change',
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
@@ -635,7 +635,7 @@ describe('PostHogTelemetryProvider', () => {
})
describe('page view', () => {
it('captures page view with page_name property', async () => {
it('captures legacy page view event with page_name property', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
@@ -645,9 +645,13 @@ describe('PostHogTelemetryProvider', () => {
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor' }
)
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
'$pageview',
expect.anything()
)
})
it('forwards additional metadata', async () => {
it('forwards additional metadata to legacy page view event', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
@@ -660,6 +664,20 @@ describe('PostHogTelemetryProvider', () => {
{ page_name: 'workflow_editor', path: '/workflows/123' }
)
})
it('queues legacy page view event before initialization', async () => {
const provider = createProvider()
provider.trackPageView('workflow_editor')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor' }
)
})
})
describe('before_send', () => {

View File

@@ -126,7 +126,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageview: 'history_change',
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
class="video-preview flex size-full min-h-55 min-w-16 flex-col px-2"
@keydown="handleKeyDown"
>
<!-- Video Wrapper -->
@@ -45,7 +45,12 @@
<video
v-if="!videoError"
:src="currentVideoUrl"
:class="cn('block size-full object-contain', showLoader && 'invisible')"
:class="
cn(
'absolute inset-0 size-full object-contain',
showLoader && 'invisible'
)
"
preload="metadata"
controls
loop

View File

@@ -79,7 +79,6 @@ function createMockNodeElement(
element.setAttribute('data-node-id', 'test-node')
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
element.getBoundingClientRect = () => {
// When --node-height is '0px', return the content-driven minimum height
const nodeHeight = element.style.getPropertyValue('--node-height')
const h = nodeHeight === '0px' ? minContentHeight : height
return {

View File

@@ -1,190 +0,0 @@
<template>
<div
class="col-span-2 grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-x-2 gap-y-1"
>
<template v-for="row in rowIndices" :key="row">
<div
class="col-span-2 mt-1 flex items-center justify-between border-t border-node-component-surface pt-1"
>
<span
class="truncate text-xs font-medium text-node-component-slot-text"
>
{{
t('dynamicGroup.group', { group_name: groupName, index: row + 1 })
}}
</span>
<button
v-if="row >= minRows"
v-tooltip.top="
t('dynamicGroup.removeGroup', { group_name: groupName })
"
type="button"
class="mr-1.75 flex cursor-pointer appearance-none border-0 bg-transparent p-0 text-node-component-slot-text/40 transition-colors duration-150 hover:text-danger-100 focus-visible:outline-none"
:aria-label="t('dynamicGroup.removeGroup', { group_name: groupName })"
@click="onRemoveRow(row)"
>
<span
class="icon-[material-symbols--close] size-4"
aria-hidden="true"
/>
</button>
</div>
<component
:is="fw.component"
v-for="fw in rowWidgets(row)"
:key="fw.name"
:model-value="fw.value"
:widget="fw.simplified"
:node-id="nodeId"
:node-type="nodeType"
class="col-span-2"
@update:model-value="fw.onUpdate"
/>
</template>
<Button
:disabled="addDisabled"
class="col-span-2 mt-1 border-0 bg-component-node-widget-background text-node-component-slot-text"
size="sm"
variant="textonly"
@click="onAddRow"
>
<span
class="mr-1 icon-[material-symbols--add] size-4"
aria-hidden="true"
/>
{{ t('dynamicGroup.addGroup', { group_name: groupName }) }}
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { WidgetState } from '@/types/widgetState'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
const { widget, nodeId, nodeType } = defineProps<{
widget: SimplifiedWidget<number>
nodeId: string
nodeType?: string
}>()
const { t } = useI18n()
const widgetValueStore = useWidgetValueStore()
const nodeDefStore = useNodeDefStore()
const group = widget.name
const node = computed(
() => app.graph?.getNodeById(toNodeId(nodeId)) as DynamicGroupNode | undefined
)
const groupState = computed(
() => node.value?.comfyDynamic?.dynamicGroup?.[group]
)
const minRows = computed(() => groupState.value?.min ?? 0)
const groupName = computed(
() => groupState.value?.groupName ?? t('dynamicGroup.defaultGroupName')
)
interface FieldWidgetView {
name: string
row: number
component: Component
simplified: SimplifiedWidget
value: WidgetValue
onUpdate: (value: WidgetValue) => void
}
function resolveWidgetState(w: IBaseWidget): WidgetState | undefined {
if (w.widgetId) return widgetValueStore.getWidget(w.widgetId)
const graphId = node.value?.graph?.rootGraph?.id
if (!graphId) return undefined
const localId = stripGraphPrefix(String(nodeId))
if (!localId) return undefined
return widgetValueStore.getWidget(widgetId(graphId, localId, w.name))
}
function toFieldView(
n: DynamicGroupNode,
w: IBaseWidget,
row: number,
fieldName: string
): FieldWidgetView {
const state = resolveWidgetState(w)
const value = state?.value ?? w.value
const simplified: SimplifiedWidget = {
name: w.name,
type: state?.type ?? w.type,
value,
label: state?.label ?? w.label ?? fieldName,
options: state?.options ?? w.options,
spec: nodeDefStore.getInputSpecForWidget(n, w.name)
}
return {
name: w.name,
row,
component: getComponent(w.type) ?? WidgetLegacy,
simplified,
value,
onUpdate: (next: WidgetValue) => {
if (state) state.value = next
w.value = next ?? undefined
w.callback?.(next)
}
}
}
const fieldWidgets = computed<FieldWidgetView[]>(() => {
const n = node.value
if (!n?.widgets) return []
const prefix = `${group}.`
const views: FieldWidgetView[] = []
for (const w of n.widgets) {
if (!w.name.startsWith(prefix)) continue
const rest = w.name.slice(prefix.length)
const dot = rest.indexOf('.')
if (dot === -1) continue
const row = Number(rest.slice(0, dot))
if (!Number.isInteger(row)) continue
views.push(toFieldView(n, w, row, rest.slice(dot + 1)))
}
return views
})
const rowIndices = computed(() =>
[...new Set(fieldWidgets.value.map((fw) => fw.row))].sort((a, b) => a - b)
)
const addDisabled = computed(
() => rowIndices.value.length >= (groupState.value?.max ?? Infinity)
)
function rowWidgets(row: number): FieldWidgetView[] {
return fieldWidgets.value.filter((fw) => fw.row === row)
}
function onAddRow() {
groupState.value?.addRow()
}
function onRemoveRow(row: number) {
groupState.value?.removeRow(row)
}
</script>

View File

@@ -1,11 +1,13 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FormDropdown from './FormDropdown.vue'
import { DROPDOWN_PANEL_CLASS } from './shared'
import type { FormDropdownItem } from './types'
function createItem(id: string, name: string): FormDropdownItem {
@@ -20,6 +22,14 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
})
}))
const transformState = vi.hoisted(() => ({ camera: { x: 0, y: 0, z: 1 } }))
vi.mock('@/renderer/core/layout/transform/useTransformState', async () => {
const { reactive } = await import('vue')
transformState.camera = reactive(transformState.camera)
return { useTransformState: () => ({ camera: transformState.camera }) }
})
const MockFormDropdownMenu = {
name: 'FormDropdownMenu',
props: [
@@ -71,6 +81,7 @@ interface MountDropdownOptions {
multiple?: boolean | number
searchQuery?: string
onUpdateSelected?: (selected: Set<string>) => void
onUpdateIsOpen?: (isOpen: boolean) => void
}
function flushPromises() {
@@ -88,10 +99,11 @@ function mountDropdown(
multiple: options.multiple,
searcher: options.searcher,
searchQuery: options.searchQuery,
'onUpdate:selected': options.onUpdateSelected
'onUpdate:selected': options.onUpdateSelected,
'onUpdate:isOpen': options.onUpdateIsOpen
},
global: {
plugins: [PrimeVue, i18n],
plugins: [PrimeVue, i18n, createPinia()],
stubs: {
FormDropdownInput: MockFormDropdownInput,
Popover: MockPopover,
@@ -123,6 +135,12 @@ async function openDropdown(user: ReturnType<typeof userEvent.setup>) {
}
describe('FormDropdown', () => {
beforeEach(() => {
transformState.camera.x = 0
transformState.camera.y = 0
transformState.camera.z = 1
})
describe('filteredItems updates when items prop changes', () => {
it('updates displayed items when items prop changes', async () => {
const { rerender } = mountDropdown([
@@ -362,6 +380,74 @@ describe('FormDropdown', () => {
expect(onUpdateSelected).not.toHaveBeenCalled()
})
it('closes on a pointerdown outside the menu and trigger', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
const outside = document.createElement('div')
document.body.appendChild(outside)
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
outside.remove()
})
it('closes when the canvas viewport moves', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
transformState.camera.x += 77
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
})
it('stays open on a pointerdown inside the menu', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
screen
.getByTestId('dropdown-menu')
.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
})
it('stays open on a pointerdown inside a body-teleported sub-popover panel', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
const panel = document.createElement('div')
panel.classList.add(DROPDOWN_PANEL_CLASS)
const option = document.createElement('button')
panel.appendChild(option)
document.body.appendChild(panel)
option.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
panel.remove()
})
it('does not select a search result from multi-select dropdowns', async () => {
const onUpdateSelected = vi.fn()
const { user } = mountDropdown(

View File

@@ -1,10 +1,17 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import {
computedAsync,
refDebounced,
unrefElement,
useEventListener
} from '@vueuse/core'
import Popover from 'primevue/popover'
import type { ComponentPublicInstance } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDismissOnCanvasGesture } from '@/renderer/extensions/vueNodes/widgets/composables/useDismissOnCanvasGesture'
import type {
FilterOption,
@@ -14,7 +21,11 @@ import type {
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import {
DROPDOWN_PANEL_CLASS,
defaultSearcher,
getDefaultSortOptions
} from './shared'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -102,6 +113,7 @@ const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerAnchorRef = useTemplateRef<HTMLElement>('triggerAnchorRef')
const menuRef = useTemplateRef<ComponentPublicInstance>('menuRef')
const triggerRef =
useTemplateRef<InstanceType<typeof FormDropdownInput>>('triggerRef')
const displayedSearchQuery = ref('')
@@ -204,6 +216,43 @@ const closeDropdown = ({ restoreFocus = false } = {}) => {
if (restoreFocus) focusTrigger()
}
/**
* Dismiss on `pointerdown` rather than PrimeVue's default `click` (mouseup) so
* the dropdown closes the instant an outside press lands, and a focused inner
* scrollbar cannot swallow the first outside click. Presses on the trigger and
* on the menu's body-teleported sub-popovers (Sort / Ownership / Base-model)
* are excluded so they keep working instead of closing the parent.
*/
useEventListener(
window,
'pointerdown',
(event) => {
if (!isOpen.value) return
const menuEl = unrefElement(menuRef)
const triggerEl = triggerAnchorRef.value
const path = event.composedPath()
if (menuEl && path.includes(menuEl)) return
if (triggerEl && path.includes(triggerEl)) return
if (path.some(isInsideDropdownPanel)) return
closeDropdown()
},
{ capture: true }
)
function isInsideDropdownPanel(target: EventTarget): boolean {
return (
target instanceof HTMLElement &&
target.classList.contains(DROPDOWN_PANEL_CLASS)
)
}
/**
* The popover is teleported to `document.body`, so canvas gestures (pan, zoom,
* box select — any input device) move the node while the popover stays put.
* Dismiss as soon as such a gesture begins.
*/
useDismissOnCanvasGesture(isOpen, () => closeDropdown())
function handleFileChange(event: Event) {
if (disabled) return
const target = event.target
@@ -268,6 +317,11 @@ async function selectTopSearchResult() {
function handleSearchEnter() {
void selectTopSearchResult()
}
function showPicker() {
triggerRef.value!.showPicker()
closeDropdown()
}
</script>
<template>
@@ -290,7 +344,7 @@ function handleSearchEnter() {
/>
<Popover
ref="popoverRef"
:dismissable="true"
:dismissable="false"
:close-on-escape="true"
unstyled
:pt="{
@@ -304,12 +358,14 @@ function handleSearchEnter() {
@hide="isOpen = false"
>
<FormDropdownMenu
ref="menuRef"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:uploadable
:filter-options
:sort-options
:show-ownership-filter
@@ -326,6 +382,7 @@ function handleSearchEnter() {
@close="closeDropdown"
@search-enter="handleSearchEnter"
@item-click="handleSelection"
@show-picker="showPicker"
@approach-end="emit('approach-end')"
/>
</Popover>

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import FormDropdownInput from './FormDropdownInput.vue'
@@ -132,4 +133,57 @@ describe('FormDropdownInput', () => {
expect(onFileChange).toHaveBeenCalledTimes(1)
})
})
describe('Exposed showPicker', () => {
/** Mount a harness that captures the FormDropdownInput instance so we can
* invoke its exposed methods, mirroring how FormDropdown drives it. */
async function mountWithRef(props: Partial<FormDropdownInputProps> = {}) {
const inputRef = ref<InstanceType<typeof FormDropdownInput> | null>(null)
const Harness = defineComponent({
components: { FormDropdownInput },
setup: () => ({
inputRef,
bindings: {
items,
selected: new Set<string>(),
maxSelectable: 1,
uploadable: true,
disabled: false,
...props
}
}),
template: '<FormDropdownInput ref="inputRef" v-bind="bindings" />'
})
render(Harness, { global: { plugins: [i18n] } })
await nextTick()
return inputRef
}
it('calls showPicker on the file input when available', async () => {
const showPickerSpy = vi.fn()
Object.defineProperty(HTMLInputElement.prototype, 'showPicker', {
value: showPickerSpy,
configurable: true,
writable: true
})
const inputRef = await mountWithRef()
inputRef.value!.showPicker()
expect(showPickerSpy).toHaveBeenCalledTimes(1)
})
it('falls back to click() when showPicker is unavailable', async () => {
// Simulate older browsers
// @ts-expect-error -- intentional removal for fallback path
delete HTMLInputElement.prototype.showPicker
const clickSpy = vi.fn()
Object.defineProperty(HTMLInputElement.prototype, 'click', {
value: clickSpy,
configurable: true,
writable: true
})
const inputRef = await mountWithRef()
inputRef.value!.showPicker()
expect(clickSpy).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -43,12 +43,29 @@ const theButtonStyle = computed(() =>
)
const buttonRef = ref<HTMLButtonElement>()
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
function focus() {
buttonRef.value?.focus()
}
defineExpose({ focus })
/**
* Open the native file picker without a user click on the input itself.
* Must be invoked synchronously from a user-initiated event handler so the
* browser's transient activation requirement is satisfied. Falls back to
* `click()` on browsers that predate showPicker (Chrome <99, Firefox <101,
* Safari <16).
*/
function showPicker() {
const input = fileInputRef.value!
if (typeof input.showPicker === 'function') {
input.showPicker()
} else {
input.click()
}
}
defineExpose({ focus, showPicker })
</script>
<template>
@@ -108,6 +125,7 @@ defineExpose({ focus })
aria-hidden="true"
/>
<input
ref="fileInputRef"
type="file"
class="absolute inset-0 -z-1 opacity-0"
:aria-label="t('g.upload')"

View File

@@ -26,6 +26,7 @@ describe('FormDropdownMenu', () => {
const defaultProps = {
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
isSelected: () => false,
uploadable: false,
filterOptions: [],
sortOptions: []
}
@@ -158,6 +159,58 @@ describe('FormDropdownMenu', () => {
expect(event.defaultPrevented).toBe(true)
})
/** Stub that surfaces `uploadable` as a data attribute and exposes a button
* that emits `show-picker`, so the parent's prop-forwarding and event
* re-emission can be asserted from the DOM. */
const FormDropdownMenuFilterStub = {
name: 'FormDropdownMenuFilter',
props: ['uploadable', 'filterOptions'],
emits: ['show-picker'],
template:
'<button data-testid="filter-stub" :data-uploadable="String(uploadable)" @click="$emit(\'show-picker\')" />'
}
it('forwards uploadable prop to FormDropdownMenuFilter', () => {
render(FormDropdownMenu, {
props: {
...defaultProps,
uploadable: true,
filterOptions: [{ name: 'All', value: 'all' }]
},
global: {
stubs: {
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
FormDropdownMenuActions: true,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
expect(screen.getByTestId('filter-stub').dataset.uploadable).toBe('true')
})
it('re-emits show-picker when FormDropdownMenuFilter emits it', async () => {
const { emitted } = render(FormDropdownMenu, {
props: {
...defaultProps,
uploadable: true,
filterOptions: [{ name: 'All', value: 'all' }]
},
global: {
stubs: {
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
FormDropdownMenuActions: true,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
await userEvent.click(screen.getByTestId('filter-stub'))
expect(emitted('show-picker')).toHaveLength(1)
})
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {

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