Compare commits

..

18 Commits

Author SHA1 Message Date
Terry Jia
e893bf0eba feat(camera-info): 3D viewport widget for CreateCameraInfo node 2026-07-16 23:40:45 -04:00
Benjamin Lu
5bce9c4874 fix: sequence Customer.io identification before auth events (#13677)
We need this fix urgently, blocking all welcome emails and fixing
several vectors.

## Summary

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

## Changes

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

## Incident evidence

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

## Review Focus

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

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

## Validation

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

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

## Rollout / backfill

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

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

## Changes

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

## Review Focus

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

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

## Screenshots (if applicable)

---------

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

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

## Changes

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

## Review Focus

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

Implements FE-1217.

## Screenshots

**Desktop**

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


**Mobile**

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


## Test plan

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

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

## Changes

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

## Review Focus

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

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

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

## Not implemented, and why

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

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

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

## Scope note

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

## Verification

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

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

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

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

Linear: FE-1239

## Changes

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

## Review Focus

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

## Red-Green Verification

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

## Test Plan

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

## Screenshots (if applicable)

Before 


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

After 


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

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

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

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

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

## Changes

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

## Review Focus

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

## Validation

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

Created by Codex


<!-- CURSOR_SUMMARY -->
---

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

---------

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

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

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

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

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

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

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

## Changes

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

## Review Focus

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

Relates to Linear **FE-910**.

## Testing

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

## Future work

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

## Demo


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

**Base branch:** `main`

---------

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

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

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

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

## Changes

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

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

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

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

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

## Tests

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

## Usage

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

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

---------

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

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

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

## Changes

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

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

Follow-ups (not this PR):

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

## Review Focus

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

## Screenshots

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

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

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

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

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

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

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

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

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

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

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

## Change

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

## Tests

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

## ADR

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

Fixes FE-1237

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

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

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

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

## Changes

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

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

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

- **Breaking**: none.

## Review Focus

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

## Credit

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

Fixes #13545
Fixes #13575

---------

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

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

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

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

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

## Changes

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

## Review Focus

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

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

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

## Screenshots (if applicable)

Not applicable.

---------

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

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

## Why this is needed

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

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

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

## The rule (design)

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

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

### In scope — examples

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

### Out of scope — examples

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

## Changes

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

## Review Focus

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

## Screenshots

### Before 


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

### After



https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
2026-07-14 14:39:20 +00:00
177 changed files with 13223 additions and 852 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { reactive, watch } from 'vue'
import { computed, reactive, watch } from 'vue'
type Faq = { id: string; question: string; answer: string }
@@ -9,6 +9,31 @@ const { faqs } = defineProps<{
faqs: readonly Faq[]
}>()
type AnswerPart = { type: 'text' | 'link'; value: string }
function parseAnswer(answer: string): AnswerPart[] {
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
const parts: AnswerPart[] = []
let lastIndex = 0
for (const match of answer.matchAll(urlPattern)) {
const start = match.index ?? 0
const url = match[0].replace(/[.,;:]+$/, '')
if (start > lastIndex) {
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
}
parts.push({ type: 'link', value: url })
lastIndex = start + url.length
}
if (lastIndex < answer.length) {
parts.push({ type: 'text', value: answer.slice(lastIndex) })
}
return parts
}
const parsedFaqs = computed(() =>
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
)
const expanded = reactive<boolean[]>(faqs.map(() => false))
watch(
@@ -40,7 +65,7 @@ function toggle(index: number) {
<!-- Right FAQ list -->
<div class="flex-1">
<div
v-for="(faq, index) in faqs"
v-for="(faq, index) in parsedFaqs"
:key="faq.id"
class="border-b border-primary-comfy-canvas/20"
>
@@ -83,8 +108,23 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
<p
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
>
<template
v-for="(part, partIndex) in faq.answerParts"
:key="partIndex"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
>{{ part.value }}</a
>
<template v-else>{{ part.value }}</template>
</template>
</p>
</section>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,8 @@ const baseRoutes = {
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp'
mcp: '/mcp',
brand: '/brand'
} as const
type Routes = typeof baseRoutes
@@ -87,6 +88,7 @@ export const externalLinks = {
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpEndpoint: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,69 +1,180 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import SectionHeader from '../../components/common/SectionHeader.vue'
import SectionLabel from '../../components/common/SectionLabel.vue'
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
const agentCommand = t('mcp.setup.option1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
interface McpClient {
id: string
name: string
step: string
command?: string
link?: { label: string; href: string }
}
const clients: McpClient[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
id: 'claude-code',
name: 'Claude Code',
step: t('mcp.setup.clients.claudeCode.step', locale),
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
step: t('mcp.setup.clients.claudeDesktop.step', locale)
},
{
id: 'cursor',
name: 'Cursor',
step: t('mcp.setup.clients.cursor.step', locale),
link: {
label: t('mcp.setup.clients.cursor.linkLabel', locale),
href: externalLinks.apiKeys
}
},
{
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: 'codex',
name: 'Codex',
step: t('mcp.setup.clients.codex.step', locale),
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
id: 'other',
name: t('mcp.setup.clients.other.name', locale),
step: t('mcp.setup.clients.other.step', locale),
link: {
label: t('mcp.setup.clients.other.linkLabel', locale),
href: externalLinks.docsMcp
}
}
]
const copyLabel = t('ui.copy', locale)
const copiedLabel = t('ui.copied', locale)
</script>
<template>
<FeatureGrid01
<section
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
>
<SectionHeader
max-width="xl"
:label="t('mcp.setup.label', locale)"
align="start"
>
{{ t('mcp.setup.heading', locale) }}
<template #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ t('mcp.setup.subtitle', locale) }}
</p>
</template>
</SectionHeader>
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.option1.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option1.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="agentCommand"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<p class="mt-auto pt-6 text-sm text-smoke-700">
{{ t('mcp.setup.skillsNote', locale)
}}<a
:href="externalLinks.mcpSkills"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ t('mcp.setup.skillsLink', locale) }}</a
>
</p>
</div>
<div
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
<h3
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
>
{{ t('mcp.setup.option2.title', locale) }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ t('mcp.setup.option2.description', locale) }}
</p>
<div class="mt-6">
<CopyableField
:value="externalLinks.mcpEndpoint"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
<TabsRoot default-value="claude-code" class="mt-6">
<TabsList
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
class="flex flex-wrap gap-2"
>
<TabsTrigger
v-for="client in clients"
:key="client.id"
:value="client.id"
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
>
{{ client.name }}
</TabsTrigger>
</TabsList>
<TabsContent
v-for="client in clients"
:key="client.id"
:value="client.id"
class="mt-4 flex min-h-24 flex-col gap-3"
>
<p class="text-sm text-smoke-700">
{{ client.step
}}<a
v-if="client.link"
:href="client.link.href"
target="_blank"
rel="noopener noreferrer"
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
>{{ client.link.label }}</a
>
</p>
<CopyableField
v-if="client.command"
:value="client.command"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</TabsContent>
</TabsRoot>
</div>
</div>
</section>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -322,6 +322,9 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Panel chrome ---
public readonly panelHeader: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
@@ -358,6 +361,7 @@ export class AssetsSidebarTab extends SidebarTab {
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')
this.panelHeader = page.locator('.comfy-vue-side-bar-header')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)

View File

@@ -40,7 +40,7 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE', exact: true })
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
@@ -57,8 +57,7 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
await expect(
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH',
exact: true
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
).toBeVisible()
})
@@ -74,9 +73,7 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: 'UNKNOWN NODE', exact: true })
.click()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
@@ -101,12 +98,10 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A',
exact: true
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B',
exact: true
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()

View File

@@ -276,3 +276,255 @@ test.describe('FE-130 assets sidebar route mocks', () => {
)
})
})
test.describe('FE-910 marquee selection and select all', () => {
test.beforeEach(async ({ jobsRoutes, page, comfyPage }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
await comfyPage.setup()
await comfyPage.menu.assetsTab.open()
})
test('Ctrl/Cmd+A selects every asset while the panel is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a marquee that begins in the panel header selects the cards', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const header = await tab.panelHeader.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!header || !beta) {
throw new Error('panel header or asset card has no layout box')
}
// Begin the rubber-band in the header (above the grid), then drag down
// across both cards.
await page.mouse.move(header.x + 24, header.y + 20)
await page.mouse.down()
await page.mouse.move(beta.x + 8, beta.y + beta.height - 8, { steps: 14 })
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd+A leaves assets unselected while the canvas is hovered', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const viewport = page.viewportSize()
if (!viewport) throw new Error('viewport size is unavailable')
// Hover the canvas (not the panel); Ctrl/Cmd+A must yield to the canvas.
await page.mouse.move(viewport.width - 100, viewport.height / 2)
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
})
test('a modifier-held marquee adds to the existing selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectedCards).toHaveCount(1)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Hold a modifier so the marquee is additive, then rubber-band over beta.
await page.keyboard.down('Control')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
})
test('a Ctrl/Cmd+Shift marquee removes the covered cards from the selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await tab.getAssetCardByName('alpha').hover()
await page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(2)
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!beta) throw new Error('beta card has no layout box')
// Ctrl+Shift makes the marquee subtractive: rubber-band over beta only.
await page.keyboard.down('Control')
await page.keyboard.down('Shift')
await page.mouse.move(beta.x + 12, beta.y + 12)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 12, beta.y + beta.height - 12, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Shift')
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(tab.getAssetCardByName('alpha')).toHaveAttribute(
'data-selected',
'true'
)
})
test('Ctrl/Cmd-dragging from an asset card starts a marquee selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
await expect(tab.selectedCards).toHaveCount(0)
const alpha = await tab.getAssetCardByName('alpha').boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!alpha || !beta) throw new Error('asset cards have no layout box')
// Ctrl bypasses card drag, so a press that begins on a card rubber-bands.
await page.keyboard.down('Control')
await page.mouse.move(alpha.x + alpha.width / 2, alpha.y + alpha.height / 2)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width - 6, beta.y + beta.height - 6, {
steps: 12
})
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
})
test('Ctrl/Cmd-dragging within a single card selects only that card', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const alpha = tab.getAssetCardByName('alpha')
const box = await alpha.boundingBox()
if (!box) throw new Error('alpha card has no layout box')
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
await page.keyboard.down('Control')
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x + 12, start.y + 12, { steps: 4 })
await page.mouse.up()
await page.keyboard.up('Control')
await expect(tab.selectedCards).toHaveCount(1)
await expect(alpha).toHaveAttribute('data-selected', 'true')
})
test('Ctrl/Cmd+A in the focused search input does not select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const query = 'alpha'
await tab.searchInput.fill(query)
await expect(tab.assetCards).toHaveCount(1)
await tab.searchInput.focus()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await expect
.poll(() =>
tab.searchInput.evaluate((el: HTMLInputElement) => {
return { start: el.selectionStart, end: el.selectionEnd }
})
)
.toEqual({ start: 0, end: query.length })
})
test('a drag starting in the search input does not marquee-select assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
const { page } = comfyPage
await expect(tab.assetCards).toHaveCount(2)
const search = await tab.searchInput.boundingBox()
const beta = await tab.getAssetCardByName('beta').boundingBox()
if (!search || !beta)
throw new Error('search box or card has no layout box')
await page.mouse.move(
search.x + search.width / 2,
search.y + search.height / 2
)
await page.mouse.down()
await page.mouse.move(beta.x + beta.width / 2, beta.y + beta.height / 2, {
steps: 12
})
await page.mouse.up()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Ctrl/Cmd+A does not select assets while an aria-modal dialog is open', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await expect(tab.assetCards).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const dialog = document.createElement('div')
dialog.id = 'test-modal'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.appendChild(dialog)
})
await tab.getAssetCardByName('alpha').hover()
await comfyPage.page.keyboard.press('ControlOrMeta+a')
await expect(tab.selectedCards).toHaveCount(0)
await comfyPage.page.evaluate(() => {
document.getElementById('test-modal')?.remove()
})
})
})

View File

@@ -23,6 +23,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const SLOT_ERROR_CLASS = /before:ring-error/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
@@ -69,6 +70,25 @@ async function selectLoadImageNodeForPaste(
}, localLoadImageId)
}
async function getInputSlotIndexByName(
comfyPage: ComfyPage,
nodeId: string,
inputName: string
): Promise<number> {
return comfyPage.page.evaluate(
({ inputName, nodeId }) => {
const graph = window.app!.canvas.graph ?? window.app!.graph
const node = graph.getNodeById(nodeId)
const index = node?.findInputSlot(inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ inputName, nodeId: toNodeId(nodeId) }
)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
@@ -139,17 +159,10 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
const modelInputIndex = await getInputSlotIndexByName(
comfyPage,
ksamplerId,
KSAMPLER_MODEL_INPUT_NAME
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
@@ -175,7 +188,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
@@ -407,5 +420,76 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('boundary-linked validation error surfaces on the subgraph host', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
const hostInputIndex = await getInputSlotIndexByName(
comfyPage,
subgraphParentId,
'positive'
)
const hostInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
subgraphParentId,
hostInputIndex
)
await expect(
innerWrapper,
'subgraph host must mount before injecting validation errors'
).toBeVisible()
await expect(
innerWrapper,
'subgraph host should start without an error ring'
).not.toHaveClass(ERROR_CLASS)
await test.step('surface the boundary-linked error on the host', async () => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'required_input_missing',
'positive',
'Required input is missing: positive'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
})
await test.step('confirm the interior node does not show the surfaced ring', async () => {
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const interiorKSamplerId =
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const interiorPositiveInputIndex = await getInputSlotIndexByName(
comfyPage,
interiorKSamplerId,
'positive'
)
const interiorPositiveSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
interiorKSamplerId,
interiorPositiveInputIndex
)
const interiorInnerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
await expect(interiorInnerWrapper).toBeVisible()
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
await expect(interiorPositiveSlotHighlight).toBeVisible()
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
SLOT_ERROR_CLASS
)
})
})
})
})

View File

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

View File

@@ -0,0 +1,74 @@
# 12. Cloud Release Notes Use the ComfyUI Version
Date: 2026-07-13
## Status
Accepted
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
The release-note system (`releaseStore`) decides whether to surface new-release
UI — the desktop toast/red-dot and the "what's new" popup — by comparing the
version of the most recent entry in the `/releases` feed against the version the
user is currently running.
`currentVersion` sourced that "current version" differently per platform:
- on Cloud, from `system_stats.system.cloud_version`,
- everywhere else, from `system_stats.system.comfyui_version`.
The `/releases` feed, however, is authored in the docs repo and its entries are
keyed by **ComfyUI** version for every project, including `project: 'cloud'`.
There is no separate cloud-versioned feed, and the "learn more" link on every
release note points at <https://docs.comfy.org/changelog>, which only lists
ComfyUI versions.
This mismatch broke the feature on Cloud (tracked as **FE-1237**):
- Cloud runs a much higher `cloud_version` (e.g. `0.160.1`) than the ComfyUI
version the feed entries carry (e.g. `0.27.1`).
- The comparison therefore resolved as `0.27.1 < 0.160.1` → "already ahead of
the latest release" → the popup's `isLatestVersion` gate never passed and the
popup never showed.
- Analytics confirmed the regression: `release_note` clicks fell from 13.4% of
clicks over 90 days to 0% over 30 days, and `cloud_release_note` was
effectively never clicked.
Two directions could fix the mismatch:
1. Give Cloud its own cloud-versioned release feed and a cloud changelog page.
2. Compare against the ComfyUI version on Cloud too, so the running version and
the feed entries share the same version namespace.
Option 1 requires infrastructure that does not exist: no cloud changelog page,
and in current practice a single person maintains the changelog in the docs
repo using ComfyUI versions, updated after each Cloud deploy completes. A
cloud-versioned popup would deep-link users to a changelog page that has no
matching entry, which is more confusing than the version label itself.
## Decision
`currentVersion` always uses `comfyui_version`, on Cloud as well as everywhere
else. `cloud_version` is no longer consulted for release-note version
comparisons.
The `/releases` request still sends `project: 'cloud'` on Cloud, so Cloud can
receive a curated subset of release notes; only the version used for comparison
changes.
## Consequences
- Cloud shows a release note once the running ComfyUI version matches the latest
published feed entry — consistent with the practice of updating the changelog
after a Cloud deploy lands. Publishing a note for a version Cloud has not yet
deployed correctly withholds the popup until the deploy catches up.
- The popup and its "learn more" link now reference a ComfyUI version that
actually exists on the changelog page.
- `cloud_version` remains available in `system_stats` for other consumers; this
decision scopes only to release-note version comparison.
- If Cloud later wants release notes tied to its own versioning, it would need a
cloud-versioned feed **and** a cloud changelog page, at which point this ADR
should be revisited.

View File

@@ -21,6 +21,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
| [0012](0012-cloud-release-notes-use-comfyui-version.md) | Cloud Release Notes Use the ComfyUI Version | Accepted | 2026-07-13 |
## Creating a New ADR

View File

@@ -57,6 +57,9 @@ const config: KnipConfig = {
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Pending integration: consumed by the useWorkspaceInvoices seam once
// #13591 (Plan & Credits tabs) lands — FE-1245
'src/composables/billing/useNextInvoice.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.48.2",
"version": "1.48.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
}
: {
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 2 : undefined,
use: {
trace: 'on-first-retry'
}
@@ -25,7 +26,7 @@ export default defineConfig({
testDir: './browser_tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: 'html',
reporter: process.env.PLAYWRIGHT_BLOB_OUTPUT_DIR ? 'blob' : 'html',
...maybeLocalOptions,
globalSetup: './browser_tests/globalSetup.ts',

View File

@@ -0,0 +1,120 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
computeTargetVersion,
isValidSemver,
parseRequirementsVersion,
parseTargetBranchOverride
} from './resolve-comfyui-release'
describe('parseRequirementsVersion', () => {
let dir: string
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-release-'))
})
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true })
})
function writeRequirements(content: string): string {
const filePath = path.join(dir, 'requirements.txt')
fs.writeFileSync(filePath, content)
return filePath
}
it('parses a pinned == version', () => {
const file = writeRequirements(
'torch\ncomfyui-frontend-package==1.45.20\nnumpy'
)
expect(parseRequirementsVersion(file)).toBe('1.45.20')
})
it('parses a >= constraint', () => {
const file = writeRequirements('comfyui-frontend-package>=2.0.3')
expect(parseRequirementsVersion(file)).toBe('2.0.3')
})
it('returns null when the package is absent', () => {
const file = writeRequirements('torch\nnumpy')
expect(parseRequirementsVersion(file)).toBeNull()
})
it('returns null when the file is missing', () => {
expect(parseRequirementsVersion(path.join(dir, 'nope.txt'))).toBeNull()
})
})
describe('isValidSemver', () => {
it('accepts a valid X.Y.Z', () => {
expect(isValidSemver('1.45.20')).toBe(true)
expect(isValidSemver('2.0.0')).toBe(true)
})
it('rejects non-three-part versions', () => {
expect(isValidSemver('1.45')).toBe(false)
expect(isValidSemver('1.45.20.1')).toBe(false)
})
it('rejects non-numeric or leading-zero-padded parts', () => {
expect(isValidSemver('1.x.0')).toBe(false)
expect(isValidSemver('v1.45.20')).toBe(false)
expect(isValidSemver('1.045.20')).toBe(false)
})
it('rejects empty / non-string input', () => {
expect(isValidSemver('')).toBe(false)
// @ts-expect-error exercising runtime guard
expect(isValidSemver(undefined)).toBe(false)
})
})
describe('parseTargetBranchOverride', () => {
it('parses core/1.47', () => {
expect(parseTargetBranchOverride('core/1.47')).toEqual({
major: 1,
minor: 47,
branch: 'core/1.47'
})
})
it('parses a major bump core/2.0', () => {
expect(parseTargetBranchOverride('core/2.0')).toEqual({
major: 2,
minor: 0,
branch: 'core/2.0'
})
})
it('rejects malformed overrides', () => {
expect(parseTargetBranchOverride('core/1')).toBeNull()
expect(parseTargetBranchOverride('1.47')).toBeNull()
expect(parseTargetBranchOverride('core/1.47.0')).toBeNull()
expect(parseTargetBranchOverride('release/1.47')).toBeNull()
expect(parseTargetBranchOverride('core/v1.47')).toBeNull()
expect(parseTargetBranchOverride('')).toBeNull()
})
})
describe('computeTargetVersion', () => {
it('bumps patch on a 2.x line when commits exist', () => {
expect(computeTargetVersion(2, 0, 'v2.0.3', true)).toBe('2.0.4')
})
it('keeps the tag version when no new commits exist', () => {
expect(computeTargetVersion(2, 0, 'v2.0.3', false)).toBe('2.0.3')
})
it('starts a fresh major.minor line at .0 when no tag exists', () => {
expect(computeTargetVersion(2, 0, null, true)).toBe('2.0.0')
expect(computeTargetVersion(1, 47, null, true)).toBe('1.47.0')
})
it('returns null for a malformed tag', () => {
expect(computeTargetVersion(1, 47, 'v1.47', true)).toBeNull()
})
})

View File

@@ -81,15 +81,72 @@ function isValidSemver(version: string): boolean {
}
/**
* Get the latest patch tag for a given minor version
* Parse a target branch override of the form `core/<major>.<minor>`.
* Returns the parsed major/minor and normalized branch, or null if malformed.
*/
function getLatestPatchTag(repoPath: string, minor: number): string | null {
function parseTargetBranchOverride(
branch: string
): { major: number; minor: number; branch: string } | null {
const match = branch.match(/^core\/(\d+)\.(\d+)$/)
if (!match) {
return null
}
return {
major: Number(match[1]),
minor: Number(match[2]),
branch
}
}
/**
* Compute the next release version for a target major.minor line.
*
* With no prior tag, the line starts at `.0`. With a prior tag, the patch is
* bumped when there are pending commits, otherwise the tagged version stands.
* Returns null if the tag is not valid semver.
*/
function computeTargetVersion(
targetMajor: number,
targetMinor: number,
latestPatchTag: string | null,
hasPendingCommits: boolean
): string | null {
if (!latestPatchTag) {
return `${targetMajor}.${targetMinor}.0`
}
const tagVersion = latestPatchTag.replace('v', '')
if (!isValidSemver(tagVersion)) {
return null
}
const existingPatch = Number(tagVersion.split('.')[2])
return hasPendingCommits
? `${targetMajor}.${targetMinor}.${existingPatch + 1}`
: tagVersion
}
/**
* Check whether a branch exists on origin in the given repo.
*/
function branchExists(branch: string, repoPath: string): boolean {
return Boolean(exec(`git rev-parse --verify origin/${branch}`, repoPath))
}
/**
* Get the latest patch tag for a given major.minor version
*/
function getLatestPatchTag(
repoPath: string,
major: number,
minor: number
): string | null {
// Fetch all tags
exec('git fetch --tags', repoPath)
// Use git's native version sorting to get the latest tag
const latestTag = exec(
`git tag -l 'v1.${minor}.*' --sort=-version:refname | head -n 1`,
`git tag -l 'v${major}.${minor}.*' --sort=-version:refname | head -n 1`,
repoPath
)
@@ -101,7 +158,7 @@ function getLatestPatchTag(repoPath: string, minor: number): string | null {
const validTagRegex = /^v\d+\.\d+\.\d+$/
if (!validTagRegex.test(latestTag)) {
console.error(
`Latest tag for minor version ${minor} is not valid semver: ${latestTag}`
`Latest tag for version ${major}.${minor} is not valid semver: ${latestTag}`
)
return null
}
@@ -132,85 +189,106 @@ function resolveRelease(
return null
}
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
const [currentMajor, currentMinor] = currentVersion.split('.').map(Number)
// Fetch all branches
exec('git fetch origin', frontendRepoPath)
// Determine target branch based on release type:
// 'patch' → target current minor (hotfix for production version)
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
const releaseTypeInput =
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
console.error(
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
)
return null
}
const releaseType: 'minor' | 'patch' = releaseTypeInput
// Target major defaults to the current pin's major, but a TARGET_BRANCH
// override (below) can retarget both major and minor.
let targetMajor = currentMajor
let targetMinor: number
let targetBranch: string
if (releaseType === 'patch') {
targetMinor = currentMinor
targetBranch = `core/1.${targetMinor}`
const targetBranchOverride = process.env.TARGET_BRANCH?.trim()
const branchExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!branchExists) {
if (targetBranchOverride) {
// Manual override takes precedence over RELEASE_TYPE / pin-derived selection.
const parsed = parseTargetBranchOverride(targetBranchOverride)
if (!parsed) {
console.error(
`Patch release requested but branch ${targetBranch} does not exist`
`Invalid TARGET_BRANCH: "${targetBranchOverride}". Expected format: core/<major>.<minor> (e.g. core/1.47 or core/2.0)`
)
return null
}
targetMajor = parsed.major
targetMinor = parsed.minor
targetBranch = parsed.branch
if (!branchExists(targetBranch, frontendRepoPath)) {
console.error(
`Manual override branch ${targetBranch} does not exist in frontend repo`
)
return null
}
console.error(
`Patch release: targeting current production branch ${targetBranch}`
`Manual override: targeting ${targetBranch} (ignoring release_type)`
)
} else {
// Try next minor first, fall back to current minor if not available
targetMinor = currentMinor + 1
targetBranch = `core/1.${targetMinor}`
const nextMinorExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!nextMinorExists) {
// Fall back to current minor for minor release
targetMinor = currentMinor
targetBranch = `core/1.${targetMinor}`
const currentMinorExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
// Determine target branch based on release type:
// 'patch' → target current minor (hotfix for production version)
// 'minor' → try next minor, fall back to current minor (bi-weekly cadence)
const releaseTypeInput =
process.env.RELEASE_TYPE?.trim().toLowerCase() || 'minor'
if (releaseTypeInput !== 'minor' && releaseTypeInput !== 'patch') {
console.error(
`Invalid RELEASE_TYPE: "${releaseTypeInput}". Expected "minor" or "patch"`
)
return null
}
const releaseType: 'minor' | 'patch' = releaseTypeInput
if (!currentMinorExists) {
if (releaseType === 'patch') {
targetMinor = currentMinor
targetBranch = `core/${targetMajor}.${targetMinor}`
if (!branchExists(targetBranch, frontendRepoPath)) {
console.error(
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
`Patch release requested but branch ${targetBranch} does not exist`
)
return null
}
console.error(
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for minor release`
`Patch release: targeting current production branch ${targetBranch}`
)
} else {
// Try next minor first, fall back to current minor if not available
targetMinor = currentMinor + 1
targetBranch = `core/${targetMajor}.${targetMinor}`
if (!branchExists(targetBranch, frontendRepoPath)) {
// Fall back to current minor for minor release
targetMinor = currentMinor
targetBranch = `core/${targetMajor}.${targetMinor}`
if (!branchExists(targetBranch, frontendRepoPath)) {
console.error(
`Neither core/${targetMajor}.${currentMinor + 1} nor core/${targetMajor}.${currentMinor} branches exist in frontend repo`
)
return null
}
console.error(
`Next minor branch core/${targetMajor}.${currentMinor + 1} not found, falling back to core/${targetMajor}.${currentMinor} for minor release`
)
}
}
}
// Get latest patch tag for target minor
const latestPatchTag = getLatestPatchTag(frontendRepoPath, targetMinor)
// Get latest patch tag for target major.minor
const latestPatchTag = getLatestPatchTag(
frontendRepoPath,
targetMajor,
targetMinor
)
let needsRelease = false
let branchHeadSha: string | null = null
let needsRelease: boolean
let branchHeadSha: string | null
let tagCommitSha: string | null = null
let targetVersion = currentVersion
let targetVersion: string
if (latestPatchTag) {
// Get commit SHA for the tag
@@ -231,34 +309,23 @@ function resolveRelease(
const commitCount = parseInt(commitsBetween, 10)
needsRelease = !isNaN(commitCount) && commitCount > 0
// Parse existing patch number and increment if needed
const tagVersion = latestPatchTag.replace('v', '')
// Validate tag version format
if (!isValidSemver(tagVersion)) {
const nextVersion = computeTargetVersion(
targetMajor,
targetMinor,
latestPatchTag,
needsRelease
)
if (!nextVersion) {
console.error(
`Invalid tag version format: ${tagVersion}. Expected format: X.Y.Z`
`Invalid tag version format: ${latestPatchTag}. Expected format: vX.Y.Z`
)
return null
}
const [, , existingPatch] = tagVersion.split('.').map(Number)
// Validate existingPatch is a valid number
if (!Number.isFinite(existingPatch) || existingPatch < 0) {
console.error(`Invalid patch number in tag: ${existingPatch}`)
return null
}
if (needsRelease) {
targetVersion = `1.${targetMinor}.${existingPatch + 1}`
} else {
targetVersion = tagVersion
}
targetVersion = nextVersion
} else {
// No tags exist for this minor version, need to create v1.{targetMinor}.0
// No tags exist for this major.minor version, need to create the .0 patch
needsRelease = true
targetVersion = `1.${targetMinor}.0`
targetVersion = `${targetMajor}.${targetMinor}.0`
branchHeadSha = exec(
`git rev-parse origin/${targetBranch}`,
frontendRepoPath
@@ -281,26 +348,41 @@ function resolveRelease(
}
}
// Main execution
const comfyuiRepoPath = process.argv[2]
const frontendRepoPath = process.argv[3] || process.cwd()
/**
* Main execution: parse args, resolve, and print the JSON result.
*/
function main(): void {
const comfyuiRepoPath = process.argv[2]
const frontendRepoPath = process.argv[3] || process.cwd()
if (!comfyuiRepoPath) {
console.error(
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
)
process.exit(1)
if (!comfyuiRepoPath) {
console.error(
'Usage: resolve-comfyui-release.ts <comfyui-repo-path> [frontend-repo-path]'
)
process.exit(1)
}
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
if (!releaseInfo) {
console.error('Failed to resolve release information')
process.exit(1)
}
// Output as JSON for GitHub Actions
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
console.log(JSON.stringify(releaseInfo, null, 2))
}
const releaseInfo = resolveRelease(comfyuiRepoPath, frontendRepoPath)
if (!releaseInfo) {
console.error('Failed to resolve release information')
process.exit(1)
// Only run when invoked directly, not when imported by tests.
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
main()
}
// Output as JSON for GitHub Actions
// oxlint-disable-next-line no-console -- stdout is captured by the workflow
console.log(JSON.stringify(releaseInfo, null, 2))
export { resolveRelease }
export {
computeTargetVersion,
isValidSemver,
parseRequirementsVersion,
parseTargetBranchOverride,
resolveRelease
}

View File

@@ -3,7 +3,10 @@ import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
import {
escapeVueI18nMessageSyntax,
normalizeI18nKey
} from '@/utils/formatUtil'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
const localePath = './src/locales/en/main.json'
@@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
}
)
console.log(`Collected ${nodeDefs.length} node definitions`)
const allDataTypesLocale = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => {
@@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
)
return allDataTypes.map((dataType) => [
normalizeI18nKey(dataType),
dataType
escapeVueI18nMessageSyntax(dataType)
])
})
.sort((a, b) => a[0].localeCompare(b[0]))
@@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const runtimeWidgets = Object.fromEntries(
Object.entries(widgetsMappings)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
.map(([key, value]) => [
normalizeI18nKey(key),
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
])
)
if (Object.keys(runtimeWidgets).length > 0) {
@@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
function extractInputs(nodeDef: ComfyNodeDefImpl) {
const inputs = Object.fromEntries(
Object.values(nodeDef.inputs).flatMap((input) => {
const name = input.name
const name =
input.name === undefined
? undefined
: escapeVueI18nMessageSyntax(input.name)
const tooltip = input.tooltip
if (name === undefined && tooltip === undefined) {
@@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const outputs = Object.fromEntries(
nodeDef.outputs.flatMap((output, i) => {
// Ignore data types if they are already translated in allDataTypesLocale.
const name = output.name in allDataTypesLocale ? undefined : output.name
const name =
output.name === undefined || output.name in allDataTypesLocale
? undefined
: escapeVueI18nMessageSyntax(output.name)
const tooltip = output.tooltip
if (name === undefined && tooltip === undefined) {
@@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
return [
normalizeI18nKey(nodeDef.name),
{
display_name: nodeDef.display_name ?? nodeDef.name,
description: nodeDef.description || undefined,
display_name: escapeVueI18nMessageSyntax(
nodeDef.display_name ?? nodeDef.name
),
description: nodeDef.description
? escapeVueI18nMessageSyntax(nodeDef.description)
: undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: extractOutputs(nodeDef)
}
@@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
nodeDefs.flatMap((nodeDef) =>
nodeDef.category
.split('/')
.map((category) => [normalizeI18nKey(category), category])
.map((category) => [
normalizeI18nKey(category),
escapeVueI18nMessageSyntax(category)
])
)
)

View File

@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
return {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver

View File

@@ -0,0 +1,219 @@
<template>
<div
class="relative size-full min-h-[300px]"
@pointerdown.stop
@mousedown.stop
>
<div
ref="container"
class="relative size-full"
data-capture-wheel="true"
tabindex="-1"
@pointerdown.stop="focusContainer"
@contextmenu.stop.prevent
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
/>
<div class="pointer-events-none absolute inset-x-0 top-0">
<div
ref="toolbar"
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<button
v-tooltip.bottom="tip(gizmosLabel)"
type="button"
:disabled="lookingThrough"
:class="
cn(
actionClass(!lookingThrough && gizmosOn),
lookingThrough && 'cursor-not-allowed opacity-40'
)
"
:aria-pressed="!lookingThrough && gizmosOn"
:aria-label="compact ? gizmosLabel : undefined"
@click="toggleGizmos"
>
<i
:class="
cn(
'size-4',
gizmosOn ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'
)
"
/>
<span v-if="!compact">{{ gizmosLabel }}</span>
</button>
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
<button
v-for="option in transformGizmoOptions"
:key="option.value"
v-tooltip.bottom="tip($t(option.labelKey))"
type="button"
:disabled="lookingThrough || !option.enabled"
:aria-pressed="!lookingThrough && transformGizmoMode === option.value"
:aria-label="compact ? $t(option.labelKey) : undefined"
:class="
cn(
actionClass(
!lookingThrough && transformGizmoMode === option.value
),
(lookingThrough || !option.enabled) &&
'cursor-not-allowed opacity-40'
)
"
@click="selectTransformGizmo(option.value)"
>
<i :class="cn('size-4', option.icon)" />
<span v-if="!compact">{{ $t(option.labelKey) }}</span>
</button>
</div>
</div>
<div class="pointer-events-none absolute inset-x-0 bottom-0">
<div
class="pointer-events-auto flex h-10 items-center justify-end gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<button
v-tooltip.top="tip(lookThroughLabel)"
type="button"
:class="
cn(iconBtnClass, lookingThrough && 'bg-button-active-surface')
"
:aria-pressed="lookingThrough"
:aria-label="lookThroughLabel"
@click="toggleLookThrough"
>
<i class="icon-[lucide--video] size-4" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
actionClass,
iconBtnClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import { useCameraInfo } from '@/composables/useCameraInfo'
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { widget, nodeId } = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
function isComponentWidget(
w: ComponentWidget<string[]> | SimplifiedWidget
): w is ComponentWidget<string[]> {
return 'node' in w && w.node !== undefined
}
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
})
}
const { t } = useI18n()
const container = ref<HTMLElement | null>(null)
const toolbar = ref<HTMLElement | null>(null)
const { width: toolbarWidth } = useElementSize(toolbar)
const compactWidthThreshold = 480
const compact = computed(
() => toolbarWidth.value > 0 && toolbarWidth.value < compactWidthThreshold
)
const gizmosOn = ref(true)
const lookingThrough = ref(false)
const transformGizmoMode = ref<TransformGizmoMode>('none')
const {
initialize,
cleanup,
handleMouseEnter,
handleMouseLeave,
setGizmosVisible,
setTransformGizmoMode,
setLookThrough,
mode
} = useCameraInfo(node as Ref<LGraphNode | null>)
const gizmosLabel = computed(() =>
gizmosOn.value ? t('load3d.hideGizmos') : t('load3d.showGizmos')
)
const lookThroughLabel = computed(() =>
lookingThrough.value ? t('load3d.exitLookThrough') : t('load3d.lookThrough')
)
const transformGizmoOptions = computed(() => [
{
value: 'none' as const,
labelKey: 'load3d.transformGizmo.none',
icon: 'icon-[lucide--ban]',
enabled: true
},
{
value: 'target' as const,
labelKey: 'load3d.transformGizmo.target',
icon: 'icon-[lucide--target]',
enabled: mode.value === 'orbit' || mode.value === 'look_at'
},
{
value: 'camera-translate' as const,
labelKey: 'load3d.transformGizmo.cameraTranslate',
icon: 'icon-[lucide--move-3d]',
enabled: mode.value === 'look_at' || mode.value === 'quaternion'
},
{
value: 'camera-rotate' as const,
labelKey: 'load3d.transformGizmo.cameraRotate',
icon: 'icon-[lucide--rotate-3d]',
enabled: mode.value === 'quaternion'
}
])
function focusContainer() {
container.value?.focus()
}
function toggleGizmos() {
gizmosOn.value = !gizmosOn.value
}
function toggleLookThrough() {
lookingThrough.value = !lookingThrough.value
}
function selectTransformGizmo(value: TransformGizmoMode) {
transformGizmoMode.value = value
}
watch(gizmosOn, (on) => setGizmosVisible(on))
watch(transformGizmoMode, (m) => setTransformGizmoMode(m))
watch(lookingThrough, (on) => setLookThrough(on))
onMounted(() => {
if (container.value) initialize(container.value)
})
onUnmounted(() => {
cleanup()
})
</script>

View File

@@ -39,6 +39,10 @@
<NodePropertiesPanel v-else />
</template>
<template #graph-canvas-panel>
<div
ref="canvasPanelBoundsRef"
class="pointer-events-none absolute inset-0"
/>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !isBuilderMode"
class="pointer-events-auto"
@@ -89,7 +93,10 @@
/>
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
<SelectionRectangle v-if="comfyAppReady" />
<SelectionRectangle
v-if="comfyAppReady"
:panel-el="canvasPanelBoundsRef ?? undefined"
/>
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
@@ -116,6 +123,7 @@ import {
onUnmounted,
ref,
shallowRef,
useTemplateRef,
watch,
watchEffect
} from 'vue'
@@ -202,6 +210,7 @@ const emit = defineEmits<{
ready: []
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const canvasPanelBoundsRef = useTemplateRef('canvasPanelBoundsRef')
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
typeof NodeSearchboxPopover
> | null>(null)

View File

@@ -0,0 +1,106 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import SelectionRectangle from './SelectionRectangle.vue'
const rafCallbacks: Array<() => void> = []
vi.mock('@vueuse/core', () => ({
useRafFn: (cb: () => void) => {
rafCallbacks.push(cb)
return { pause: vi.fn(), resume: vi.fn() }
}
}))
const mockCanvas = ref<unknown>(null)
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get canvas() {
return mockCanvas.value
}
})
}))
function createPanelEl() {
const panel = document.createElement('div')
vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 300, top: 0, right: 1000, bottom: 800 })
)
return panel
}
function dragRectangle(eDown: [number, number], eMove: [number, number]) {
const canvasEl = document.createElement('canvas')
vi.spyOn(canvasEl, 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({ left: 0, top: 0, right: 1000, bottom: 800 })
)
mockCanvas.value = {
canvas: canvasEl,
dragging_rectangle: true,
pointer: {
eDown: { safeOffsetX: eDown[0], safeOffsetY: eDown[1] },
eMove: { safeOffsetX: eMove[0], safeOffsetY: eMove[1] }
}
}
rafCallbacks[rafCallbacks.length - 1]()
}
describe('SelectionRectangle', () => {
afterEach(() => {
rafCallbacks.length = 0
mockCanvas.value = null
document.body.replaceChildren()
vi.restoreAllMocks()
})
it('clips the rectangle to the canvas panel when dragged over the sidebar', async () => {
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
dragRectangle([100, 100], [800, 400])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('300px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('500px')
expect(rect.style.height).toBe('300px')
})
it('leaves a rectangle within the panel unchanged', async () => {
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
dragRectangle([400, 100], [600, 300])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('400px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('200px')
expect(rect.style.height).toBe('200px')
})
it('normalizes and clips a rectangle dragged up-and-left', async () => {
render(SelectionRectangle, { props: { panelEl: createPanelEl() } })
dragRectangle([800, 400], [100, 100])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('300px')
expect(rect.style.top).toBe('100px')
expect(rect.style.width).toBe('500px')
expect(rect.style.height).toBe('300px')
})
it('renders unclamped edges when the canvas panel is absent', async () => {
render(SelectionRectangle)
dragRectangle([100, 100], [800, 400])
await nextTick()
const rect = screen.getByTestId('selection-rectangle')
expect(rect.style.left).toBe('100px')
expect(rect.style.width).toBe('700px')
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
v-show="isVisible"
data-testid="selection-rectangle"
class="pointer-events-none absolute z-9999 border border-blue-400 bg-blue-500/20"
:style="rectangleStyle"
/>
@@ -11,6 +12,13 @@ import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { clipRectToBounds } from '@/utils/mathUtil'
import type { RectEdges } from '@/utils/mathUtil'
const { panelEl } = defineProps<{
/** Clip surface owned by the caller; the rectangle renders unclipped when absent. */
panelEl?: HTMLElement
}>()
const canvasStore = useCanvasStore()
@@ -20,17 +28,18 @@ const selectionRect = ref<{
w: number
h: number
} | null>(null)
const panelBounds = ref<RectEdges>()
useRafFn(() => {
const canvas = canvasStore.canvas
if (!canvas) {
selectionRect.value = null
return
}
if (!canvas) return
const { pointer, dragging_rectangle } = canvas
if (dragging_rectangle && pointer.eDown && pointer.eMove) {
if (!selectionRect.value) {
panelBounds.value = getCanvasPanelBounds(canvas.canvas)
}
const x = pointer.eDown.safeOffsetX
const y = pointer.eDown.safeOffsetY
const w = pointer.eMove.safeOffsetX - x
@@ -39,25 +48,47 @@ useRafFn(() => {
selectionRect.value = { x, y, w, h }
} else {
selectionRect.value = null
panelBounds.value = undefined
}
})
const isVisible = computed(() => selectionRect.value !== null)
function getCanvasPanelBounds(
canvasEl: HTMLCanvasElement
): RectEdges | undefined {
if (!panelEl) return undefined
const panel = panelEl.getBoundingClientRect()
const canvas = canvasEl.getBoundingClientRect()
return {
left: panel.left - canvas.left,
top: panel.top - canvas.top,
right: panel.right - canvas.left,
bottom: panel.bottom - canvas.top
}
}
const rectangleStyle = computed(() => {
const rect = selectionRect.value
if (!rect) return {}
const left = rect.w >= 0 ? rect.x : rect.x + rect.w
const top = rect.h >= 0 ? rect.y : rect.y + rect.h
const width = Math.abs(rect.w)
const height = Math.abs(rect.h)
const edges: RectEdges = {
left: rect.w >= 0 ? rect.x : rect.x + rect.w,
top: rect.h >= 0 ? rect.y : rect.y + rect.h,
right: rect.w >= 0 ? rect.x + rect.w : rect.x,
bottom: rect.h >= 0 ? rect.y + rect.h : rect.y
}
const bounds = panelBounds.value
const { left, top, right, bottom } = bounds
? clipRectToBounds(edges, bounds)
: edges
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`
width: `${right - left}px`,
height: `${bottom - top}px`
}
})
</script>

View File

@@ -72,6 +72,8 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:use-custom-up="cameraConfig!.useCustomUp"
:has-custom-up="cameraConfig!.hasCustomUp ?? false"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -13,6 +13,33 @@
>
<i class="pi pi-camera text-lg text-base-foreground" />
</Button>
<Button
v-if="hasCustomUp"
v-tooltip.right="{
value: useCustomUp
? $t('load3d.useNaturalUp')
: $t('load3d.useCustomUp'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="
useCustomUp ? $t('load3d.useNaturalUp') : $t('load3d.useCustomUp')
"
@click="toggleUp"
>
<i
:class="
cn(
useCustomUp
? 'icon-[lucide--compass]'
: 'icon-[lucide--rotate-ccw]',
'text-lg text-base-foreground'
)
"
/>
</Button>
<PopupSlider
v-if="showFOVButton"
v-model="fov"
@@ -27,13 +54,24 @@ import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
import Button from '@/components/ui/button/Button.vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hasCustomUp = false } = defineProps<{
hasCustomUp?: boolean
}>()
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const useCustomUp = defineModel<boolean>('useCustomUp', { default: false })
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
}
const toggleUp = () => {
useCustomUp.value = !useCustomUp.value
}
</script>

View File

@@ -248,14 +248,19 @@
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<LocateNodeButton
:label="
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.label
})
"
@locate="handleLocateNode(item.nodeId)"
/>
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
@@ -327,7 +332,6 @@ import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import LocateNodeButton from './LocateNodeButton.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'

View File

@@ -36,10 +36,15 @@
>
<i class="icon-[lucide--monitor-x] size-4" />
</Button>
<LocateNodeButton
:label="t('rightSidePanel.locateNode')"
@locate="handleLocateNode"
/>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
</div>
@@ -172,7 +177,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import LocateNodeButton from './LocateNodeButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import type { ErrorCardData, ErrorItem } from './types'

View File

@@ -1,57 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import LocateNodeButton from '@/components/rightSidePanel/errors/LocateNodeButton.vue'
describe('LocateNodeButton', () => {
it('exposes the label as the button aria-label', () => {
render(LocateNodeButton, { props: { label: 'Locate node on canvas' } })
expect(
screen.getByRole('button', { name: 'Locate node on canvas' })
).toHaveAttribute('aria-label', 'Locate node on canvas')
})
it('emits locate when clicked', async () => {
const user = userEvent.setup()
const { emitted } = render(LocateNodeButton, {
props: { label: 'Locate node on canvas' }
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(emitted().locate).toHaveLength(1)
})
it('emits locate on keyboard activation', async () => {
const user = userEvent.setup()
const { emitted } = render(LocateNodeButton, {
props: { label: 'Locate node on canvas' }
})
await user.tab()
await user.keyboard('{Enter}')
expect(emitted().locate).toHaveLength(1)
})
it('stops click propagation so an ancestor handler does not also fire', async () => {
const user = userEvent.setup()
const onAncestorClick = vi.fn()
render({
components: { LocateNodeButton },
setup: () => ({ onAncestorClick }),
template:
'<div @click="onAncestorClick"><LocateNodeButton label="Locate node on canvas" /></div>'
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onAncestorClick).not.toHaveBeenCalled()
})
})

View File

@@ -1,23 +0,0 @@
<template>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="label"
@click.stop="emit('locate')"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { label } = defineProps<{
label: string
}>()
const emit = defineEmits<{
locate: []
}>()
</script>

View File

@@ -66,7 +66,7 @@ const i18n = createI18n({
search: 'Search'
},
rightSidePanel: {
locateNodeFor: 'Locate {item}',
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installing: 'Installing...',
@@ -162,7 +162,7 @@ describe('MissingPackGroupRow', () => {
})
expect(
screen.queryByRole('button', { name: 'Locate OnlyNode' })
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
@@ -263,7 +263,9 @@ describe('MissingPackGroupRow', () => {
})
})
await user.click(screen.getByRole('button', { name: 'Locate OnlyNode' }))
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
@@ -272,7 +274,9 @@ describe('MissingPackGroupRow', () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'Locate MissingA' }))
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
@@ -291,7 +295,7 @@ describe('MissingPackGroupRow', () => {
})
})
expect(
screen.queryByRole('button', { name: 'Locate NoId' })
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
@@ -308,11 +312,8 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Locate WithId' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate WithoutId' })
).not.toBeInTheDocument()
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(1)
})
})

View File

@@ -154,15 +154,16 @@
</span>
</Button>
</div>
<LocateNodeButton
<Button
v-if="primaryLocatableNodeType"
:label="
t('rightSidePanel.locateNodeFor', {
item: getLabel(primaryLocatableNodeType)
})
"
@locate="handleLocateNode(primaryLocatableNodeType)"
/>
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
@@ -202,13 +203,16 @@
{{ getLabel(nodeType) }}
</span>
</span>
<LocateNodeButton
<Button
v-if="isLocatableNodeType(nodeType)"
:label="
t('rightSidePanel.locateNodeFor', { item: getLabel(nodeType) })
"
@locate="handleLocateNode(nodeType)"
/>
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
@@ -224,7 +228,6 @@ import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from './selectionEmphasis'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import LocateNodeButton from '@/components/rightSidePanel/errors/LocateNodeButton.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'

View File

@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: true,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
@@ -127,6 +129,8 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
getExecutionIdByNode,
getNodeByExecutionId
@@ -493,6 +497,47 @@ describe('useErrorGroups', () => {
)
})
it('groups lifted boundary errors under the host node card', async () => {
const { store, groups } = createErrorGroups()
const { rootGraph, host } = createBoundaryLinkedSubgraph({
interiorType: 'InteriorClass'
})
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
})
store.lastNodeErrors = {
'12:5': nodeError(
[
validationError(
'required_input_missing',
'seed_input',
{},
'Required input is missing'
)
],
'InteriorClass'
)
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
expect(card.nodeId).toBe('12')
expect(card.title).toBe(host.title)
expect(card.errors[0].displayDetails).toBe(
`${host.title} is missing a required input: seed`
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -382,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionErrorStore.lastNodeErrors) return
if (!executionErrorStore.surfacedNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
executionErrorStore.surfacedNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue

View File

@@ -1,5 +1,6 @@
<template>
<SidebarTabTemplate
ref="panelRef"
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
v-bind="$attrs"
>
@@ -100,18 +101,19 @@
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
v-else
:assets="displayAssets"
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
<div v-else class="size-full">
<AssetsSidebarGridView
:assets="displayAssets"
:is-selected
:show-output-count
:get-output-count
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
</div>
</div>
</template>
<template #footer>
@@ -125,6 +127,13 @@
/>
</template>
</SidebarTabTemplate>
<Teleport to="body">
<div
v-if="marqueeStyle"
class="pointer-events-none fixed z-9999 border border-primary-background bg-primary-background/20"
:style="marqueeStyle"
/>
</Teleport>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
@@ -151,6 +160,7 @@
<script setup lang="ts">
import {
unrefElement,
useAsyncState,
useDebounceFn,
useStorage,
@@ -164,6 +174,7 @@ import {
onMounted,
onUnmounted,
ref,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -182,6 +193,7 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetGridSelection } from '@/platform/assets/composables/useAssetGridSelection'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -239,7 +251,7 @@ const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
const showOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
}
@@ -259,7 +271,10 @@ const outputAssets = useAssetsApi('output')
// Asset selection
const {
isSelected,
selectedIds,
handleAssetClick,
selectAll,
setSelectedIds,
hasSelection,
clearSelection,
getSelectedAssets,
@@ -270,6 +285,12 @@ const {
deactivate: deactivateSelection
} = useAssetSelection()
const panelRef = useTemplateRef('panelRef')
const marqueePanelRef = computed(() => {
const el = unrefElement(panelRef)
return el instanceof HTMLElement ? el : undefined
})
const {
downloadAssets,
deleteAssets,
@@ -337,6 +358,16 @@ const visibleAssets = computed(() => {
return listViewSelectableAssets.value
})
const { marqueeStyle } = useAssetGridSelection({
marqueeContainerRef: marqueePanelRef,
hoverTargetRef: marqueePanelRef,
getAssets: () => visibleAssets.value,
getSelectedIds: () => [...selectedIds.value],
setSelectedIds,
selectAll,
isEnabled: () => !isListView.value
})
const previewableVisibleAssets = computed(() =>
visibleAssets.value.filter((asset) =>
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
@@ -575,7 +606,7 @@ const handleDeselectAll = () => {
}
const handleEmptySpaceClick = () => {
if (hasSelection) {
if (hasSelection.value) {
clearSelection()
}
}

View File

@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import type { SubscriptionInfo } from '@/composables/billing/types'
import type {
Plan,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
import type { NextInvoiceInputs } from './useNextInvoice'
import { deriveNextInvoice } from './useNextInvoice'
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'STANDARD',
duration: 'MONTHLY',
planSlug: 'standard-monthly',
renewalDate: '2026-08-01T00:00:00Z',
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
function makePlan(overrides: Partial<Plan> = {}): Plan {
return {
slug: 'standard-monthly',
tier: 'STANDARD',
duration: 'MONTHLY',
price_cents: 2000,
credits_cents: 2000,
max_seats: 1,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 2000,
total_credits_cents: 2000
},
...overrides
}
}
// Both stop prices are per-month figures; price_cents is the discounted
// figure, kept distinct from list_price_cents and credits so a regression to
// either fails the amount assertions.
const teamCreditStops: TeamCreditStops = {
default_stop_index: 0,
stops: [
{
id: 'stop-320',
credits: 67520,
monthly: { list_price_cents: 32000, price_cents: 30400 },
yearly: { list_price_cents: 32000, price_cents: 28800 }
},
{
id: 'stop-640',
credits: 135040,
monthly: { list_price_cents: 64000, price_cents: 60800 },
yearly: { list_price_cents: 64000, price_cents: 57600 }
}
]
}
function makeInputs(
overrides: Partial<NextInvoiceInputs> = {}
): NextInvoiceInputs {
return {
subscription: makeSubscription(),
planSlug: 'standard-monthly',
plans: [makePlan()],
teamCreditStops: null,
currentTeamCreditStop: null,
...overrides
}
}
describe(deriveNextInvoice, () => {
it('resolves a monthly invoice from the current plan price by slug', () => {
expect(deriveNextInvoice(makeInputs())).toEqual({
amountCents: 2000,
renewalDate: '2026-08-01T00:00:00Z',
duration: 'MONTHLY'
})
})
it('prefers the subscribed team credit stop over the plan price', () => {
const inputs = makeInputs({
subscription: makeSubscription({ planSlug: 'team-pro-monthly' }),
planSlug: 'team-pro-monthly',
plans: [makePlan({ slug: 'team-pro-monthly', price_cents: 9999 })],
teamCreditStops,
currentTeamCreditStop: {
id: 'stop-320',
credits_monthly: 32000,
stop_usd: 320
}
})
expect(deriveNextInvoice(inputs)?.amountCents).toBe(30400)
})
it('multiplies the per-month yearly stop price by 12 for annual subs', () => {
const inputs = makeInputs({
subscription: makeSubscription({ duration: 'ANNUAL' }),
teamCreditStops,
currentTeamCreditStop: {
id: 'stop-640',
credits_monthly: 64000,
stop_usd: 640
}
})
expect(deriveNextInvoice(inputs)).toEqual({
amountCents: 57600 * 12,
renewalDate: '2026-08-01T00:00:00Z',
duration: 'ANNUAL'
})
})
it('uses the annual plan price_cents as the yearly total, unscaled', () => {
const inputs = makeInputs({
subscription: makeSubscription({
duration: 'ANNUAL',
planSlug: 'standard-yearly'
}),
planSlug: 'standard-yearly',
plans: [
makePlan({
slug: 'standard-yearly',
duration: 'ANNUAL',
price_cents: 21600
})
]
})
expect(deriveNextInvoice(inputs)).toEqual({
amountCents: 21600,
renewalDate: '2026-08-01T00:00:00Z',
duration: 'ANNUAL'
})
})
it('passes a null renewalDate through (scheduled-cancellation window)', () => {
const inputs = makeInputs({
subscription: makeSubscription({ renewalDate: null })
})
expect(deriveNextInvoice(inputs)?.renewalDate).toBeNull()
})
it('falls back to the plan price when the stop is not in the ladder', () => {
const inputs = makeInputs({
teamCreditStops,
currentTeamCreditStop: {
id: 'stop-unknown',
credits_monthly: 1000,
stop_usd: 10
}
})
expect(deriveNextInvoice(inputs)?.amountCents).toBe(2000)
})
it.for([
['no subscription', { subscription: null }],
[
'inactive subscription',
{ subscription: makeSubscription({ isActive: false }) }
],
[
'cancelled subscription',
{ subscription: makeSubscription({ isCancelled: true }) }
],
['unresolvable plan slug', { planSlug: 'unknown-plan' }],
[
'annual sub whose slug resolves only to a monthly plan',
{ subscription: makeSubscription({ duration: 'ANNUAL' }) }
],
['empty plan list (legacy billing)', { plans: [] }],
['zero-price plan (free tier)', { plans: [makePlan({ price_cents: 0 })] }]
] satisfies [string, Partial<NextInvoiceInputs>][])(
'returns null for %s',
([, overrides]) => {
expect(deriveNextInvoice(makeInputs(overrides))).toBeNull()
}
)
})

View File

@@ -0,0 +1,96 @@
import { computed } from 'vue'
import type { SubscriptionInfo } from '@/composables/billing/types'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type {
CurrentTeamCreditStop,
Plan,
SubscriptionDuration,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
export interface NextInvoiceInputs {
subscription: SubscriptionInfo | null
planSlug: string | null
plans: Plan[]
teamCreditStops: TeamCreditStops | null
currentTeamCreditStop: CurrentTeamCreditStop | null
}
export interface NextInvoice {
amountCents: number
renewalDate: string | null
duration: SubscriptionDuration
}
/**
* Next invoice for the Settings > Invoices banner; annual subscriptions show
* their yearly total and renewal date. Unit semantics: credit-stop
* `yearly.price_cents` is a per-month figure (x12 for the invoice total)
* while an ANNUAL plan's `price_cents` is already the yearly total.
* `renewalDate` is BE-computed and passed through untouched — backends own
* period math including month-end bias — and goes null once a cancellation
* is scheduled. Cancelled/inactive return null because the cancelled Toast
* owns that state. A non-positive resolved amount also returns null (free
* tier can look like an active subscription with no real invoice).
* Intentionally limited to the subscription price: usage/overage pending
* charges are excluded until the backend exposes an authoritative
* upcoming-invoice amount.
*/
export function deriveNextInvoice({
subscription,
planSlug,
plans,
teamCreditStops,
currentTeamCreditStop
}: NextInvoiceInputs): NextInvoice | null {
if (!subscription?.isActive || subscription.isCancelled) return null
const duration = subscription.duration === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
const stop = teamCreditStops?.stops.find(
({ id }) => id === currentTeamCreditStop?.id
)
const plan = plans.find(
(candidate) =>
candidate.slug === planSlug && candidate.duration === duration
)
const amountCents = stop
? duration === 'ANNUAL'
? stop.yearly.price_cents * 12
: stop.monthly.price_cents
: plan?.price_cents
if (!amountCents || amountCents <= 0) return null
return {
amountCents,
renewalDate: subscription.renewalDate,
duration
}
}
/**
* Callers own billing-context initialization; a null invoice hides the
* banner.
*/
export function useNextInvoice() {
const {
subscription,
currentPlanSlug,
plans,
teamCreditStops,
currentTeamCreditStop
} = useBillingContext()
const nextInvoice = computed(() =>
deriveNextInvoice({
subscription: subscription.value,
planSlug: currentPlanSlug.value,
plans: plans.value,
teamCreditStops: teamCreditStops.value,
currentTeamCreditStop: currentTeamCreditStop.value
})
)
return { nextInvoice }
}

View File

@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
nodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
): () => void {
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
const stop = watch(
[
lastNodeErrors,
nodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
showErrorsTab
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
nodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),

View File

@@ -0,0 +1,209 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
interface ViewportInstance {
ctorArgs: unknown[]
applyState: ReturnType<typeof vi.fn>
setGizmosVisible: ReturnType<typeof vi.fn>
setTransformGizmoMode: ReturnType<typeof vi.fn>
setLookThrough: ReturnType<typeof vi.fn>
remove: ReturnType<typeof vi.fn>
viewport: {
updateStatusMouseOnScene: ReturnType<typeof vi.fn>
updateStatusMouseOnNode: ReturnType<typeof vi.fn>
refreshViewport: ReturnType<typeof vi.fn>
}
}
const { ViewportMock, instances, addAlert } = vi.hoisted(() => {
const instances: ViewportInstance[] = []
const ViewportMock = vi.fn(function (...ctorArgs: unknown[]) {
const instance: ViewportInstance = {
ctorArgs,
applyState: vi.fn(),
setGizmosVisible: vi.fn(),
setTransformGizmoMode: vi.fn(),
setLookThrough: vi.fn(),
remove: vi.fn(),
viewport: {
updateStatusMouseOnScene: vi.fn(),
updateStatusMouseOnNode: vi.fn(),
refreshViewport: vi.fn()
}
}
instances.push(instance)
return instance
})
return { ViewportMock, instances, addAlert: vi.fn() }
})
vi.mock('@/extensions/core/cameraInfo/CameraInfoViewport', () => ({
CameraInfoViewport: ViewportMock
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert })
}))
import { useCameraInfo } from './useCameraInfo'
interface FakeWidget {
name: string
value: unknown
callback?: (value: unknown, ...rest: unknown[]) => void
}
interface FakeNode {
widgets: FakeWidget[]
onMouseEnter?: () => void
onMouseLeave?: () => void
}
function makeNode(values: Record<string, unknown>): FakeNode {
return {
widgets: Object.entries(values).map(([name, value]) => ({ name, value }))
}
}
function widget(node: FakeNode, name: string): FakeWidget {
const found = node.widgets.find((w) => w.name === name)
if (!found) throw new Error(`missing widget ${name}`)
return found
}
function nodeRef(node: FakeNode) {
return ref(node) as unknown as Ref<LGraphNode | null>
}
beforeEach(() => {
instances.length = 0
ViewportMock.mockClear()
addAlert.mockClear()
})
describe('useCameraInfo', () => {
it('constructs the viewport from widget state and exposes the mode', () => {
const node = makeNode({ mode: 'look_at', 'mode.distance': 7 })
const container = document.createElement('div')
const camera = useCameraInfo(nodeRef(node))
camera.initialize(container)
expect(ViewportMock).toHaveBeenCalledOnce()
const [ctorContainer, initialState] = instances[0].ctorArgs as [
HTMLElement,
{ mode: string; orbit: { distance: number } }
]
expect(ctorContainer).toBe(container)
expect(initialState.mode).toBe('look_at')
expect(initialState.orbit.distance).toBe(7)
expect(camera.mode.value).toBe('look_at')
})
it('does nothing when the node is null', () => {
const camera = useCameraInfo(ref(null))
camera.initialize(document.createElement('div'))
expect(ViewportMock).not.toHaveBeenCalled()
})
it('alerts and does not throw when the viewport fails to construct', () => {
ViewportMock.mockImplementationOnce(() => {
throw new Error('webgl unavailable')
})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
expect(() => camera.initialize(document.createElement('div'))).not.toThrow()
expect(addAlert).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it('forwards toolbar actions to the viewport', () => {
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
camera.initialize(document.createElement('div'))
camera.setGizmosVisible(false)
camera.setTransformGizmoMode('camera-rotate')
camera.setLookThrough(true)
expect(instances[0].setGizmosVisible).toHaveBeenCalledWith(false)
expect(instances[0].setTransformGizmoMode).toHaveBeenCalledWith(
'camera-rotate'
)
expect(instances[0].setLookThrough).toHaveBeenCalledWith(true)
})
it('ignores toolbar actions before initialization', () => {
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
expect(() => camera.setLookThrough(true)).not.toThrow()
expect(instances).toHaveLength(0)
})
it('re-applies state to the viewport when a widget changes, keeping the original callback', () => {
const node = makeNode({ mode: 'orbit', target_x: 0 })
const original = vi.fn()
widget(node, 'target_x').callback = original
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
const targetX = widget(node, 'target_x')
targetX.value = 3
targetX.callback!(3)
expect(original).toHaveBeenCalledWith(3)
const applied = instances[0].applyState.mock.lastCall?.[0] as {
target: { x: number }
}
expect(applied.target.x).toBe(3)
})
it('updates the mode ref when the mode widget changes', () => {
const node = makeNode({ mode: 'orbit' })
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
const modeWidget = widget(node, 'mode')
modeWidget.value = 'quaternion'
modeWidget.callback!('quaternion')
expect(camera.mode.value).toBe('quaternion')
})
it('routes node hover into the viewport status flags', () => {
const node = makeNode({ mode: 'orbit' })
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
camera.handleMouseEnter()
camera.handleMouseLeave()
node.onMouseEnter?.()
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
true
)
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
false
)
expect(instances[0].viewport.updateStatusMouseOnNode).toHaveBeenCalledWith(
true
)
})
it('removes the viewport and restores widget callbacks on cleanup', () => {
const node = makeNode({ mode: 'orbit', target_x: 0 })
const original = vi.fn()
widget(node, 'target_x').callback = original
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
camera.cleanup()
expect(instances[0].remove).toHaveBeenCalledOnce()
expect(widget(node, 'target_x').callback).toBe(original)
})
})

View File

@@ -0,0 +1,151 @@
import { ref, toRaw, toRef } from 'vue'
import type { MaybeRef } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { CameraInfoViewport } from '@/extensions/core/cameraInfo/CameraInfoViewport'
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
import { DEFAULT_CAMERA_INFO_STATE } from '@/extensions/core/cameraInfo/types'
import type { CameraInfoMode } from '@/extensions/core/cameraInfo/types'
import {
readStateFromWidgets,
writeWidgetValue
} from '@/extensions/core/cameraInfo/widgetBridge'
import type { NodeWithWidgets } from '@/extensions/core/cameraInfo/widgetBridge'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'
type WidgetCallback = (value: unknown, ...rest: unknown[]) => void
interface MutableWidget {
name: string
value: unknown
callback?: WidgetCallback
}
const WIDGET_NAMES = [
'mode',
'camera_type',
'target_x',
'target_y',
'target_z',
'roll',
'fov',
'zoom',
'mode.yaw',
'mode.pitch',
'mode.distance',
'mode.position_x',
'mode.position_y',
'mode.position_z',
'mode.quat_x',
'mode.quat_y',
'mode.quat_z',
'mode.quat_w'
] as const
export function useCameraInfo(nodeRef: MaybeRef<LGraphNode | null>) {
const node = toRef(nodeRef)
let viewport: CameraInfoViewport | null = null
const mode = ref<CameraInfoMode>(DEFAULT_CAMERA_INFO_STATE.mode)
const wrappedWidgets: { widget: MutableWidget; original?: WidgetCallback }[] =
[]
const wrappedSet = new WeakSet<MutableWidget>()
const initialize = (container: HTMLElement): void => {
const raw = toRaw(node.value)
if (!raw || !container) return
try {
const initialState = readStateFromWidgets(raw as NodeWithWidgets)
mode.value = initialState.mode
viewport = new CameraInfoViewport(container, initialState, {
onHandleDrag: (fieldName, value) => {
writeWidgetValue(raw as NodeWithWidgets, fieldName, value)
}
})
wireWidgetsToOverlay(raw as NodeWithWidgets)
wireNodeMouseStatus(raw as LGraphNode)
} catch (error) {
console.error('Failed to initialize CameraInfoViewport:', error)
useToastStore().addAlert('Failed to initialize Camera Info viewport.')
}
}
const cleanup = (): void => {
unwireWidgets()
viewport?.remove()
viewport = null
}
const handleMouseEnter = (): void => {
viewport?.viewport.updateStatusMouseOnScene(true)
viewport?.viewport.refreshViewport()
}
const handleMouseLeave = (): void => {
viewport?.viewport.updateStatusMouseOnScene(false)
}
const setGizmosVisible = (on: boolean): void => {
viewport?.setGizmosVisible(on)
}
const setTransformGizmoMode = (gizmoMode: TransformGizmoMode): void => {
viewport?.setTransformGizmoMode(gizmoMode)
}
const setLookThrough = (on: boolean): void => {
viewport?.setLookThrough(on)
}
function wireNodeMouseStatus(target: LGraphNode): void {
target.onMouseEnter = useChainCallback(target.onMouseEnter, () => {
viewport?.viewport.updateStatusMouseOnNode(true)
viewport?.viewport.refreshViewport()
})
target.onMouseLeave = useChainCallback(target.onMouseLeave, () => {
viewport?.viewport.updateStatusMouseOnNode(false)
})
}
function wireWidgetsToOverlay(target: NodeWithWidgets): void {
if (!target.widgets) return
for (const name of WIDGET_NAMES) {
const widget = target.widgets.find(
(w): w is MutableWidget => w.name === name
)
if (!widget || wrappedSet.has(widget)) continue
wrappedSet.add(widget)
const original = widget.callback
const isModeWidget = widget.name === 'mode'
wrappedWidgets.push({ widget, original })
widget.callback = (value, ...rest) => {
original?.call(widget, value, ...rest)
if (isModeWidget) wireWidgetsToOverlay(target)
if (!viewport) return
const state = readStateFromWidgets(target)
mode.value = state.mode
viewport.applyState(state)
}
}
}
function unwireWidgets(): void {
for (const { widget, original } of wrappedWidgets) {
widget.callback = original
}
wrappedWidgets.length = 0
}
return {
initialize,
cleanup,
handleMouseEnter,
handleMouseLeave,
setGizmosVisible,
setTransformGizmoMode,
setLookThrough,
mode
}
}

View File

@@ -143,7 +143,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const cameraConfig = ref<CameraConfig>({
cameraType: 'perspective',
fov: 75
fov: 75,
hasCustomUp: false,
useCustomUp: false
})
const lightConfig = ref<LightConfig>({
@@ -534,6 +536,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
if (newValue.hasCustomUp) {
load3d.setUseCustomUp(newValue.useCustomUp ?? false)
}
}
markDirty()
},
@@ -871,6 +876,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
cameraTypeChange: (value: string) => {
cameraConfig.value.cameraType = value as CameraType
},
cameraUpStateChange: (value: {
hasCustomUp: boolean
usingCustomUp: boolean
}) => {
cameraConfig.value.hasCustomUp = value.hasCustomUp
cameraConfig.value.useCustomUp = value.usingCustomUp
},
showGridChange: (value: boolean) => {
sceneConfig.value.showGrid = value
},

View File

@@ -0,0 +1,299 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createBoundaryLinkedSubgraph,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { toNodeId } from '@/types/nodeId'
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('liftNodeErrorsToBoundary', () => {
it('lifts a boundary-linked slot error to the host', () => {
const { host, rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual({
'12': {
class_type: host.title,
dependent_outputs: [],
errors: [
expect.objectContaining({
type: 'required_input_missing',
extra_info: expect.objectContaining({
input_name: 'seed',
source_execution_id: '12:5',
source_input_name: 'seed_input'
})
})
]
}
})
})
it('lifts a promoted-widget value error to the host input', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('CheckpointLoaderSimple')
interior.id = toNodeId(5)
const input = interior.addInput('ckpt_name', 'COMBO')
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
values: ['present.safetensors']
})
input.widget = { name: widget.name }
subgraph.add(interior)
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
true
)
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('value_not_in_list', 'ckpt_name', {
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
])
})
expect(result['12'].errors[0].extra_info).toMatchObject({
input_name: 'ckpt_name',
source_execution_id: '12:5',
source_input_name: 'ckpt_name',
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
})
it('recurses through nested boundary-linked hosts', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
outerHost.title = 'Outer Host'
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
})
expect(Object.keys(result)).toEqual(['1'])
expect(result['1'].class_type).toBe(outerHost.title)
expect(result['1'].errors[0].extra_info).toMatchObject({
input_name: 'seed',
source_execution_id: '1:2:3',
source_input_name: 'seed_input'
})
})
it('keeps errors on ordinary interior data-flow links', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const source = new LGraphNode('SourceNode')
source.id = toNodeId(4)
source.addOutput('seed', '*')
subgraph.add(source)
const target = new LGraphNode('TargetNode')
target.id = toNodeId(5)
target.addInput('seed_input', '*')
subgraph.add(target)
source.connect(0, target, 0)
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps errors without a liftable subject on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing'),
validationError('exception_during_validation', 'seed_input'),
validationError('dependency_cycle', 'seed_input'),
validationError(
'custom_validation_failed',
'seed_input',
{ received_value: 'image.png' },
'Invalid image file'
)
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps unknown typed validation errors on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('future_backend_validation_type', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('splits liftable and non-liftable errors from the same node entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input'),
validationError('exception_during_validation', 'seed_input')
])
})
expect(result['12'].errors).toHaveLength(1)
expect(result['12'].errors[0].type).toBe('required_input_missing')
expect(result['12:5'].errors).toHaveLength(1)
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
})
it('merges a lifted error into an existing host entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12': {
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output'],
errors: [validationError('value_smaller_than_min', 'other')]
},
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result['12']).toMatchObject({
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output']
})
expect(result['12'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('keeps own errors before lifted errors for nested host keys', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({ rootGraph })
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
]),
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
})
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('preserves empty error entries unchanged', () => {
const rootGraph = createTestRootGraph()
const errors = {
'12': nodeError([], 'ExtraRootNode')
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('fails open without mutating the input record', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('InteriorNode')
interior.id = toNodeId(5)
interior.addInput('unlinked', '*')
subgraph.add(interior)
const errors = {
'99:5': nodeError([validationError('required_input_missing', 'x')]),
'12:5': nodeError([
validationError('required_input_missing', 'missing'),
validationError('value_not_in_list', 'unlinked')
])
}
const original = structuredClone(errors)
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual(original)
expect(errors).toEqual(original)
expect(result).not.toBe(errors)
})
})

View File

@@ -0,0 +1,193 @@
import { groupBy, partition } from 'es-toolkit'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeError } from '@/schemas/apiSchema'
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
export interface LiftedErrorExtraInfo {
input_name: string
source_execution_id: string
source_input_name: string
}
export interface LiftedSurface {
hostExecId: NodeExecutionId
hostInputName: string
}
interface ErrorPlacement {
kind: 'own' | 'lifted'
targetExecId: string
error: NodeValidationError
}
export function getLiftedErrorSource(
error: NodeValidationError
): LiftedErrorExtraInfo | null {
const extraInfo = error.extra_info
if (!extraInfo) return null
const { input_name, source_execution_id, source_input_name } = extraInfo
if (
typeof input_name !== 'string' ||
typeof source_execution_id !== 'string' ||
typeof source_input_name !== 'string'
) {
return null
}
return { input_name, source_execution_id, source_input_name }
}
function getHostExecutionId(executionId: string): NodeExecutionId | null {
const separatorIndex = executionId.lastIndexOf(':')
if (separatorIndex <= 0) return null
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
}
/**
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
* Walks one host per level and stops at the last resolvable surface, so an
* unresolvable deeper host falls back to the shallower one (fail-open).
*/
export function resolveLiftChain(
rootGraph: LGraph,
executionId: string,
inputName: string
): LiftedSurface[] {
const chain: LiftedSurface[] = []
let currentExecId = executionId
let currentInputName = inputName
for (;;) {
const node = getNodeByExecutionId(rootGraph, currentExecId)
const graph = node?.graph
if (!node || !graph || !isSubgraph(graph)) break
const slot = node.inputs?.find((input) => input.name === currentInputName)
if (slot?.link == null) break
const subgraphInput = graph
.getLink(slot.link)
?.resolve(graph)?.subgraphInput
if (!subgraphInput) break
const hostExecId = getHostExecutionId(currentExecId)
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
chain.push({ hostExecId, hostInputName: subgraphInput.name })
currentExecId = hostExecId
currentInputName = subgraphInput.name
}
return chain
}
function createEmptyNodeError(nodeError: NodeError): NodeError {
return {
...nodeError,
errors: []
}
}
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
function createLiftedHostEntry(
rootGraph: LGraph,
hostExecId: string
): NodeError {
return {
class_type:
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
dependent_outputs: [],
errors: []
}
}
function toErrorPlacement(
rootGraph: LGraph,
executionId: string,
error: NodeValidationError
): ErrorPlacement {
const inputName = error.extra_info?.input_name
const surface =
inputName && !isNodeLevelValidationError(error)
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
: undefined
if (!inputName || !surface) {
return {
kind: 'own',
targetExecId: executionId,
error
}
}
const liftedExtraInfo: LiftedErrorExtraInfo = {
input_name: surface.hostInputName,
source_execution_id: executionId,
source_input_name: inputName
}
return {
kind: 'lifted',
targetExecId: surface.hostExecId,
error: {
...error,
extra_info: {
...error.extra_info,
...liftedExtraInfo
}
}
}
}
export function liftNodeErrorsToBoundary(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError>
): Record<string, NodeError> {
const output: Record<string, NodeError> = {}
const placements = Object.entries(nodeErrors).flatMap(
([executionId, nodeError]) =>
nodeError.errors.map((error) =>
toErrorPlacement(rootGraph, executionId, error)
)
)
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
if (nodeError.errors.length === 0) {
output[executionId] = createEmptyNodeError(nodeError)
}
}
const placementsByTarget = groupBy(
placements,
(placement) => placement.targetExecId
)
for (const [targetExecId, targetPlacements] of Object.entries(
placementsByTarget
)) {
const baseEntry = nodeErrors[targetExecId]
? createEmptyNodeError(nodeErrors[targetExecId])
: createLiftedHostEntry(rootGraph, targetExecId)
const [ownErrors, liftedErrors] = partition(
targetPlacements,
(placement) => placement.kind === 'own'
)
output[targetExecId] = {
...baseEntry,
errors: [...ownErrors, ...liftedErrors].map(
(placement) => placement.error
)
}
}
return output
}

View File

@@ -0,0 +1,40 @@
import { nextTick } from 'vue'
import CameraInfo from '@/components/cameraInfo/CameraInfo.vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CreateCameraInfo',
getCustomWidgets() {
return {
CAMERA_INFO_STATE(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'camera_info_state',
component: CameraInfo,
inputSpec: {
name: 'camera_info_state',
type: 'CAMERA_INFO_STATE',
isPreview: false
},
options: {}
})
widget.type = 'cameraInfo'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'CreateCameraInfo') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 360), Math.max(oldHeight, 480)])
await nextTick()
}
})

View File

@@ -0,0 +1,148 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { CameraInfoOverlay } from './CameraInfoOverlay'
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
function setupOverlay(initial?: Partial<CameraInfoState>) {
const overlay = new CameraInfoOverlay({
...DEFAULT_CAMERA_INFO_STATE,
...initial
})
const scene = new THREE.Scene()
overlay.attach(scene)
return { overlay, scene }
}
function findByName(scene: THREE.Scene, name: string): THREE.Object3D | null {
for (const child of scene.children) {
if (child.name === name) return child
}
return null
}
describe('CameraInfoOverlay', () => {
let ctx: ReturnType<typeof setupOverlay>
beforeEach(() => {
ctx = setupOverlay()
})
afterEach(() => {
ctx.overlay.dispose()
})
describe('attach / detach', () => {
it('adds reference + subject cameras + camera helper to the scene', () => {
expect(findByName(ctx.scene, 'CameraInfoReference')).not.toBeNull()
const subjectCameras = ctx.scene.children.filter(
(c) =>
c instanceof THREE.PerspectiveCamera ||
c instanceof THREE.OrthographicCamera
)
expect(subjectCameras).toHaveLength(2)
expect(
ctx.scene.children.some((c) => c instanceof THREE.CameraHelper)
).toBe(true)
})
it('detach removes everything the overlay added', () => {
ctx.overlay.detach()
expect(findByName(ctx.scene, 'CameraInfoReference')).toBeNull()
expect(
ctx.scene.children.some(
(c) =>
c instanceof THREE.PerspectiveCamera ||
c instanceof THREE.OrthographicCamera ||
c instanceof THREE.CameraHelper
)
).toBe(false)
})
})
describe('applyState — orbit mode', () => {
it('positions the active subject camera per yaw / pitch / distance', () => {
ctx.overlay.applyState({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const cam = ctx.overlay.getSubjectCamera()
expect(cam.position.x).toBeCloseTo(0)
expect(cam.position.y).toBeCloseTo(0)
expect(cam.position.z).toBeCloseTo(5)
})
it('updates fov on the perspective subject camera', () => {
ctx.overlay.applyState({
...DEFAULT_CAMERA_INFO_STATE,
fov: 70
})
const cam = ctx.overlay.getSubjectCamera() as THREE.PerspectiveCamera
expect(cam.fov).toBe(70)
})
})
describe('camera type swap', () => {
it('toggling to orthographic swaps the active subject camera and rebuilds the helper', () => {
const initialHelper = ctx.scene.children.find(
(c) => c instanceof THREE.CameraHelper
)
ctx.overlay.applyState({
...DEFAULT_CAMERA_INFO_STATE,
cameraType: 'orthographic'
})
expect(ctx.overlay.getSubjectCamera()).toBeInstanceOf(
THREE.OrthographicCamera
)
const newHelper = ctx.scene.children.find(
(c) => c instanceof THREE.CameraHelper
)
expect(newHelper).toBeDefined()
expect(newHelper).not.toBe(initialHelper)
})
})
describe('POV (onActiveCameraChange)', () => {
it('hides the camera helper when the render camera IS the subject camera', () => {
const subjectCam = ctx.overlay.getSubjectCamera()
ctx.overlay.onActiveCameraChange(subjectCam)
const helper = ctx.scene.children.find(
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
)!
expect(helper.visible).toBe(false)
})
it('shows the camera helper when render camera is some other camera', () => {
const subjectCam = ctx.overlay.getSubjectCamera()
ctx.overlay.onActiveCameraChange(subjectCam)
const otherCam = new THREE.PerspectiveCamera()
ctx.overlay.onActiveCameraChange(otherCam)
const helper = ctx.scene.children.find(
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
)!
expect(helper.visible).toBe(true)
})
})
describe('getState immutability', () => {
it('returns a deep clone the caller cannot mutate to affect overlay', () => {
const snapshot = ctx.overlay.getState()
snapshot.orbit.yaw = 999
const next = ctx.overlay.getState()
expect(next.orbit.yaw).not.toBe(999)
expect(next.target).not.toBe(snapshot.target)
})
})
})

View File

@@ -0,0 +1,213 @@
import * as THREE from 'three'
import type { SceneOverlay } from '@/extensions/core/load3d/interfaces'
import { computeSubjectTransform } from './cameraTransform'
import {
DEFAULT_CAMERA_INFO_STATE,
type CameraInfoCameraType,
type CameraInfoState
} from './types'
const REFERENCE_CUBE_SIZE = 1
const SUBJECT_CAMERA_NEAR = 0.1
const SUBJECT_CAMERA_FAR = 1000
const ORTHO_FRUSTUM_HALF = 1
export class CameraInfoOverlay implements SceneOverlay {
private scene: THREE.Scene | null = null
private state: CameraInfoState
private readonly subjectPerspective: THREE.PerspectiveCamera
private readonly subjectOrthographic: THREE.OrthographicCamera
private subjectCamera: THREE.Camera
private cameraHelper: THREE.CameraHelper | null = null
private readonly referenceGroup: THREE.Group
private readonly referenceCube: THREE.Mesh
private readonly referenceCubeEdges: THREE.LineSegments
private readonly axesHelper: THREE.AxesHelper
private renderCamera: THREE.Camera | null = null
private disposed = false
constructor(initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE) {
this.state = cloneState(initialState)
this.subjectPerspective = new THREE.PerspectiveCamera(
this.state.fov,
1,
SUBJECT_CAMERA_NEAR,
SUBJECT_CAMERA_FAR
)
this.subjectOrthographic = new THREE.OrthographicCamera(
-ORTHO_FRUSTUM_HALF,
ORTHO_FRUSTUM_HALF,
ORTHO_FRUSTUM_HALF,
-ORTHO_FRUSTUM_HALF,
SUBJECT_CAMERA_NEAR,
SUBJECT_CAMERA_FAR
)
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
const cubeGeometry = new THREE.BoxGeometry(
REFERENCE_CUBE_SIZE,
REFERENCE_CUBE_SIZE,
REFERENCE_CUBE_SIZE
)
this.referenceCube = new THREE.Mesh(
cubeGeometry,
new THREE.MeshStandardMaterial({
color: 0xb8bcc4,
roughness: 0.55,
metalness: 0.15
})
)
this.referenceCube.position.set(0, REFERENCE_CUBE_SIZE / 2, 0)
this.referenceCubeEdges = new THREE.LineSegments(
new THREE.EdgesGeometry(cubeGeometry),
new THREE.LineBasicMaterial({
color: 0x1a1a1a,
transparent: true,
opacity: 0.4
})
)
this.referenceCube.add(this.referenceCubeEdges)
this.axesHelper = new THREE.AxesHelper(REFERENCE_CUBE_SIZE * 1.25)
this.referenceGroup = new THREE.Group()
this.referenceGroup.name = 'CameraInfoReference'
this.referenceGroup.add(this.referenceCube)
this.referenceGroup.add(this.axesHelper)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.referenceGroup)
scene.add(this.subjectPerspective)
scene.add(this.subjectOrthographic)
this.rebuildCameraHelper()
this.applyStateToScene()
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.referenceGroup)
this.scene.remove(this.subjectPerspective)
this.scene.remove(this.subjectOrthographic)
if (this.cameraHelper) this.scene.remove(this.cameraHelper)
this.scene = null
}
update(_delta: number): void {
this.cameraHelper?.update()
}
onActiveCameraChange(camera: THREE.Camera): void {
this.renderCamera = camera
this.refreshHelperVisibility()
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.disposeCameraHelper()
this.referenceCube.geometry.dispose()
;(this.referenceCube.material as THREE.Material).dispose()
this.referenceCubeEdges.geometry.dispose()
;(this.referenceCubeEdges.material as THREE.Material).dispose()
this.axesHelper.dispose()
}
getSubjectCamera(): THREE.Camera {
return this.subjectCamera
}
setHelperVisible(visible: boolean): void {
if (this.cameraHelper) this.cameraHelper.visible = visible
}
getState(): CameraInfoState {
return cloneState(this.state)
}
applyState(next: CameraInfoState): void {
const cameraTypeChanged = next.cameraType !== this.state.cameraType
const modeChanged = next.mode !== this.state.mode
this.state = cloneState(next)
if (cameraTypeChanged) {
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
this.rebuildCameraHelper()
}
this.applyStateToScene()
if (modeChanged || cameraTypeChanged) this.refreshHelperVisibility()
}
private applyStateToScene(): void {
const { position, quaternion } = computeSubjectTransform(this.state)
this.subjectCamera.position.copy(position)
this.subjectCamera.quaternion.copy(quaternion)
this.subjectCamera.updateMatrixWorld(true)
if (this.subjectCamera instanceof THREE.PerspectiveCamera) {
this.subjectCamera.fov = this.state.fov
this.subjectCamera.zoom = this.state.zoom
this.subjectCamera.updateProjectionMatrix()
} else if (this.subjectCamera instanceof THREE.OrthographicCamera) {
this.subjectCamera.zoom = this.state.zoom
this.subjectCamera.updateProjectionMatrix()
}
this.cameraHelper?.update()
}
private subjectCameraFor(type: CameraInfoCameraType): THREE.Camera {
return type === 'perspective'
? this.subjectPerspective
: this.subjectOrthographic
}
private rebuildCameraHelper(): void {
this.disposeCameraHelper()
if (!this.scene) return
this.cameraHelper = new THREE.CameraHelper(this.subjectCamera)
this.scene.add(this.cameraHelper)
this.refreshHelperVisibility()
}
private disposeCameraHelper(): void {
if (!this.cameraHelper) return
if (this.scene) this.scene.remove(this.cameraHelper)
this.cameraHelper.geometry.dispose()
const material = this.cameraHelper.material as
| THREE.Material
| THREE.Material[]
if (Array.isArray(material)) material.forEach((m) => m.dispose())
else material.dispose()
this.cameraHelper = null
}
private refreshHelperVisibility(): void {
if (!this.cameraHelper) return
this.cameraHelper.visible = this.renderCamera !== this.subjectCamera
}
}
function cloneState(state: CameraInfoState): CameraInfoState {
return {
mode: state.mode,
target: { ...state.target },
roll: state.roll,
fov: state.fov,
zoom: state.zoom,
cameraType: state.cameraType,
orbit: { ...state.orbit },
lookAt: { position: { ...state.lookAt.position } },
quaternion: {
position: { ...state.quaternion.position },
quat: { ...state.quaternion.quat }
}
}
}

View File

@@ -0,0 +1,305 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createViewport3dMock } = vi.hoisted(() => ({
createViewport3dMock: vi.fn()
}))
vi.mock('@/extensions/core/load3d/createViewport3d', () => ({
createViewport3d: createViewport3dMock
}))
import { CameraInfoViewport } from './CameraInfoViewport'
function makeViewportStub() {
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 600
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
canvas.setPointerCapture = vi.fn()
canvas.releasePointerCapture = vi.fn()
canvas.hasPointerCapture = vi.fn(() => false)
let postRender: (() => void) | null = null
return {
sceneManager: { scene: new THREE.Scene() },
cameraManager: { activeCamera: new THREE.PerspectiveCamera() },
controlsManager: { controls: { enabled: true } },
viewHelperManager: { visibleViewHelper: vi.fn() },
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
setScissorTest: vi.fn(),
setClearColor: vi.fn(),
clear: vi.fn(),
render: vi.fn()
},
domElement: canvas,
setOverlay: vi.fn(),
addPostRenderCallback: vi.fn((cb: () => void) => {
postRender = cb
return vi.fn()
}),
setExternalActiveCamera: vi.fn(),
forceRender: vi.fn(),
remove: vi.fn(),
runPostRender: () => postRender?.()
}
}
describe('CameraInfoViewport look-through', () => {
let stub: ReturnType<typeof makeViewportStub>
let viewport: CameraInfoViewport
beforeEach(() => {
stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
viewport = new CameraInfoViewport(document.createElement('div'))
})
afterEach(() => {
viewport.remove()
})
it('renders the main viewport through the subject camera', () => {
viewport.setLookThrough(true)
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
viewport.overlay.getSubjectCamera()
)
expect(viewport.orbitHandles.isVisible()).toBe(false)
})
it('restores the orbit view and keeps the gnomon hidden on exit', () => {
viewport.setLookThrough(true)
stub.viewHelperManager.visibleViewHelper.mockClear()
viewport.setLookThrough(false)
expect(stub.setExternalActiveCamera).toHaveBeenLastCalledWith(null)
expect(viewport.orbitHandles.isVisible()).toBe(true)
expect(stub.viewHelperManager.visibleViewHelper).toHaveBeenLastCalledWith(
false
)
})
it('is idempotent for repeated toggles', () => {
viewport.setLookThrough(true)
viewport.setLookThrough(true)
expect(stub.setExternalActiveCamera).toHaveBeenCalledTimes(1)
})
it('re-syncs the external camera when the camera type changes while looking through', () => {
viewport.setLookThrough(true)
stub.setExternalActiveCamera.mockClear()
const state = viewport.overlay.getState()
viewport.applyState({ ...state, cameraType: 'orthographic' })
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
viewport.overlay.getSubjectCamera()
)
})
it('leaves the external camera untouched on state changes outside look-through', () => {
viewport.applyState(viewport.overlay.getState())
expect(stub.setExternalActiveCamera).not.toHaveBeenCalled()
})
it('keeps handles hidden while looking through even when gizmos are toggled', () => {
viewport.setLookThrough(true)
viewport.setGizmosVisible(false)
expect(viewport.orbitHandles.isVisible()).toBe(false)
})
})
describe('CameraInfoViewport gizmo visibility', () => {
let stub: ReturnType<typeof makeViewportStub>
let viewport: CameraInfoViewport
beforeEach(() => {
stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
viewport = new CameraInfoViewport(document.createElement('div'))
})
afterEach(() => {
viewport.remove()
})
it('hides orbit handles when gizmos are turned off', () => {
expect(viewport.orbitHandles.isVisible()).toBe(true)
viewport.setGizmosVisible(false)
expect(viewport.orbitHandles.isVisible()).toBe(false)
viewport.setGizmosVisible(true)
expect(viewport.orbitHandles.isVisible()).toBe(true)
})
it('reveals the target handle in target transform mode', () => {
viewport.setTransformGizmoMode('target')
expect(viewport.targetHandle.isVisible()).toBe(true)
})
it('enables the camera handle for camera-rotate in quaternion mode', () => {
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
viewport.setTransformGizmoMode('camera-rotate')
expect(viewport.cameraHandle.isVisible()).toBe(true)
})
})
describe('CameraInfoViewport subject preview', () => {
let stub: ReturnType<typeof makeViewportStub>
let viewport: CameraInfoViewport
beforeEach(() => {
stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
viewport = new CameraInfoViewport(document.createElement('div'))
})
afterEach(() => {
viewport.remove()
})
it('renders the corner preview after each frame in the editing view', () => {
stub.runPostRender()
expect(stub.renderer.render).toHaveBeenCalledOnce()
})
it('renders the preview for an orthographic subject camera too', () => {
viewport.applyState({
...viewport.overlay.getState(),
cameraType: 'orthographic'
})
stub.renderer.render.mockClear()
stub.runPostRender()
expect(stub.renderer.render).toHaveBeenCalledOnce()
})
it('skips the corner preview while looking through', () => {
viewport.setLookThrough(true)
stub.renderer.render.mockClear()
stub.runPostRender()
expect(stub.renderer.render).not.toHaveBeenCalled()
})
})
function dispatchPointer(
target: HTMLElement,
type: string,
clientX: number,
clientY: number
): void {
const event = new MouseEvent(type, { clientX, clientY, button: 0 })
Object.defineProperty(event, 'pointerId', { value: 1 })
target.dispatchEvent(event)
}
describe('CameraInfoViewport look-through drag', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('rotates the subject on left-drag while looking through', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.setLookThrough(true)
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
expect(onHandleDrag).toHaveBeenCalledWith('mode.yaw', expect.any(Number))
viewport.remove()
})
it('dollies the subject on wheel while looking through', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.setLookThrough(true)
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
expect(onHandleDrag).toHaveBeenCalledWith(
'mode.distance',
expect.any(Number)
)
viewport.remove()
})
it('ignores the wheel when not looking through', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
expect(onHandleDrag).not.toHaveBeenCalled()
viewport.remove()
})
it('does not rotate on drag once look-through is exited', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.setLookThrough(true)
viewport.setLookThrough(false)
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
expect(onHandleDrag).not.toHaveBeenCalled()
viewport.remove()
})
})

View File

@@ -0,0 +1,699 @@
import * as THREE from 'three'
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
import { createViewport3d } from '@/extensions/core/load3d/createViewport3d'
import type { Load3DOptions } from '@/extensions/core/load3d/interfaces'
import { CameraInfoOverlay } from './CameraInfoOverlay'
import { computeSubjectTransform } from './cameraTransform'
import {
CameraHandle,
type CameraHandleMode,
type CameraHandleTransform
} from './handles/CameraHandle'
import { OrbitHandles, type OrbitHandleType } from './handles/OrbitHandles'
import { RollHandle } from './handles/RollHandle'
import { TargetHandle } from './handles/TargetHandle'
import { pickHandleAtPointer } from './handles/handlePicking'
import {
pointToDistance,
pointToPitchAngle,
pointToYawAngle
} from './handles/orbitDragMath'
import { pointToRollAngle } from './handles/rollDragMath'
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
import type { LookThroughResult } from './lookThroughDragMath'
import {
DEFAULT_CAMERA_INFO_STATE,
type CameraInfoFieldName,
type CameraInfoMode,
type CameraInfoState
} from './types'
const PREVIEW_WIDTH = 200
const PREVIEW_HEIGHT = 150
const PREVIEW_PADDING = 8
const PREVIEW_BORDER_COLOR = 0x2a2a2a
const LOOK_THROUGH_SENSITIVITY = 0.005
type DragHandleType = OrbitHandleType | 'roll'
export type TransformGizmoMode =
| 'none'
| 'target'
| 'camera-translate'
| 'camera-rotate'
const FIELD_NAME_FOR: Record<OrbitHandleType, CameraInfoFieldName> = {
yaw: 'mode.yaw',
pitch: 'mode.pitch',
distance: 'mode.distance'
}
export interface CameraInfoViewportOptions extends Load3DOptions {
onHandleDrag?: (fieldName: CameraInfoFieldName, value: number) => void
}
interface DragState {
type: DragHandleType
pointerId: number
}
export class CameraInfoViewport {
readonly viewport: Viewport3d
readonly overlay: CameraInfoOverlay
readonly orbitHandles: OrbitHandles
readonly rollHandle: RollHandle
readonly targetHandle: TargetHandle
readonly cameraHandle: CameraHandle
private readonly disposePostRender: () => void
private readonly onHandleDrag?: CameraInfoViewportOptions['onHandleDrag']
private readonly raycaster = new THREE.Raycaster()
private readonly pointer = new THREE.Vector2()
private dragState: DragState | null = null
private lookThroughDrag: {
pointerId: number
lastX: number
lastY: number
} | null = null
private pendingRotation: { dx: number; dy: number } | null = null
private pendingDolly: number | null = null
private inputFrame: number | null = null
private hoveredHandle: DragHandleType | null = null
private gizmosOn = true
private lookingThrough = false
private transformGizmoMode: TransformGizmoMode = 'none'
constructor(
container: HTMLElement,
initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE,
options?: CameraInfoViewportOptions
) {
this.onHandleDrag = options?.onHandleDrag
this.viewport = createViewport3d(container, options)
this.viewport.viewHelperManager.visibleViewHelper(false)
this.overlay = new CameraInfoOverlay(initialState)
this.viewport.setOverlay(this.overlay)
this.orbitHandles = new OrbitHandles()
this.orbitHandles.attach(this.viewport.sceneManager.scene)
this.orbitHandles.update(initialState)
this.rollHandle = new RollHandle()
this.rollHandle.attach(this.viewport.sceneManager.scene)
this.rollHandle.update(initialState)
this.targetHandle = new TargetHandle(
this.viewport.cameraManager.activeCamera,
this.viewport.domElement,
(dragging) => {
this.viewport.controlsManager.controls.enabled = !dragging
},
(target) => {
const next: CameraInfoState = { ...this.overlay.getState(), target }
this.applyDerivedState(next)
this.onHandleDrag?.('target_x', target.x)
this.onHandleDrag?.('target_y', target.y)
this.onHandleDrag?.('target_z', target.z)
}
)
this.targetHandle.attach(this.viewport.sceneManager.scene)
this.targetHandle.setTarget(initialState.target)
this.cameraHandle = new CameraHandle(
this.viewport.cameraManager.activeCamera,
this.viewport.domElement,
(dragging) => {
this.viewport.controlsManager.controls.enabled = !dragging
},
(transform, mode) => this.handleCameraDrag(transform, mode)
)
this.cameraHandle.attach(this.viewport.sceneManager.scene)
this.syncCameraHandleSubject(initialState)
this.attachPointerHandlers()
this.disposePostRender = this.viewport.addPostRenderCallback(() =>
this.onPostRender()
)
}
applyState(state: CameraInfoState): void {
this.overlay.applyState(state)
this.orbitHandles.update(state)
this.rollHandle.update(state)
this.targetHandle.setTarget(state.target)
this.syncCameraHandleSubject(state)
this.refreshGizmoVisibility()
if (this.lookingThrough) {
this.viewport.setExternalActiveCamera(this.overlay.getSubjectCamera())
}
this.viewport.forceRender()
}
setGizmosVisible(on: boolean): void {
if (this.gizmosOn === on) return
this.gizmosOn = on
this.refreshGizmoVisibility()
this.viewport.forceRender()
}
setTransformGizmoMode(mode: TransformGizmoMode): void {
if (this.transformGizmoMode === mode) return
this.transformGizmoMode = mode
this.refreshGizmoVisibility()
this.viewport.forceRender()
}
setLookThrough(on: boolean): void {
if (this.lookingThrough === on) return
this.lookingThrough = on
this.lookThroughDrag = null
this.cancelInputFrame()
this.canvas.style.cursor = ''
this.refreshGizmoVisibility()
if (on) this.fitSubjectAspect()
this.viewport.setExternalActiveCamera(
on ? this.overlay.getSubjectCamera() : null
)
if (!on) this.viewport.viewHelperManager.visibleViewHelper(false)
}
remove(): void {
this.detachPointerHandlers()
this.cancelInputFrame()
this.canvas.style.cursor = ''
this.disposePostRender()
this.orbitHandles.dispose()
this.rollHandle.dispose()
this.targetHandle.dispose()
this.cameraHandle.dispose()
this.viewport.remove()
}
private refreshGizmoVisibility(): void {
if (this.lookingThrough) {
this.orbitHandles.setVisible(false)
this.rollHandle.setVisible(false)
this.targetHandle.setVisible(false)
this.cameraHandle.setVisible(false)
return
}
const mode = this.overlay.getState().mode
this.orbitHandles.setVisible(this.gizmosOn && mode === 'orbit')
this.rollHandle.setVisible(this.gizmosOn && rollApplies(mode))
const wantTarget =
this.transformGizmoMode === 'target' && targetApplies(mode)
this.targetHandle.setVisible(wantTarget)
const wantTranslate =
this.transformGizmoMode === 'camera-translate' &&
cameraTranslateApplies(mode)
const wantRotate =
this.transformGizmoMode === 'camera-rotate' && cameraRotateApplies(mode)
const wantCamera = wantTranslate || wantRotate
if (wantCamera)
this.cameraHandle.setMode(wantRotate ? 'rotate' : 'translate')
this.cameraHandle.setVisible(wantCamera)
}
private syncCameraHandleSubject(state: CameraInfoState): void {
const { position, quaternion } = computeSubjectTransform(state)
this.cameraHandle.setSubject(
{ x: position.x, y: position.y, z: position.z },
{ x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w }
)
}
private applyDerivedState(next: CameraInfoState): void {
this.overlay.applyState(next)
this.orbitHandles.update(next)
this.rollHandle.update(next)
this.targetHandle.setTarget(next.target)
this.syncCameraHandleSubject(next)
this.refreshGizmoVisibility()
this.viewport.forceRender()
}
private handleCameraDrag(
transform: CameraHandleTransform,
mode: CameraHandleMode
): void {
const state = this.overlay.getState()
const next = nextStateForCameraDrag(state, transform, mode)
this.applyDerivedState(next)
if (mode === 'translate') {
this.onHandleDrag?.('mode.position_x', transform.position.x)
this.onHandleDrag?.('mode.position_y', transform.position.y)
this.onHandleDrag?.('mode.position_z', transform.position.z)
} else {
this.onHandleDrag?.('mode.quat_x', transform.quaternion.x)
this.onHandleDrag?.('mode.quat_y', transform.quaternion.y)
this.onHandleDrag?.('mode.quat_z', transform.quaternion.z)
this.onHandleDrag?.('mode.quat_w', transform.quaternion.w)
}
}
private get canvas(): HTMLCanvasElement {
return this.viewport.domElement
}
private attachPointerHandlers(): void {
const canvas = this.canvas
canvas.addEventListener('pointerdown', this.onPointerDown)
canvas.addEventListener('pointermove', this.onPointerMove)
canvas.addEventListener('pointerup', this.onPointerUp)
canvas.addEventListener('pointercancel', this.onPointerUp)
canvas.addEventListener('pointerleave', this.onPointerLeave)
canvas.addEventListener('wheel', this.onWheel, { passive: false })
}
private detachPointerHandlers(): void {
const canvas = this.canvas
canvas.removeEventListener('pointerdown', this.onPointerDown)
canvas.removeEventListener('pointermove', this.onPointerMove)
canvas.removeEventListener('pointerup', this.onPointerUp)
canvas.removeEventListener('pointercancel', this.onPointerUp)
canvas.removeEventListener('pointerleave', this.onPointerLeave)
canvas.removeEventListener('wheel', this.onWheel)
}
private readonly onPointerLeave = (): void => {
if (this.dragState || this.lookThroughDrag) return
this.setHoveredHandle(null)
}
private readonly onWheel = (event: WheelEvent): void => {
if (!this.lookingThrough) return
event.preventDefault()
event.stopPropagation()
this.queueDolly(event.deltaY)
}
private updatePointer(event: PointerEvent): void {
const rect = this.canvas.getBoundingClientRect()
this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
}
private pickableTargetsFor(mode: CameraInfoMode): THREE.Object3D[] {
const targets: THREE.Object3D[] = []
if (mode === 'orbit') {
targets.push(...this.orbitHandles.pickableMeshes())
}
if (rollApplies(mode)) {
targets.push(...this.rollHandle.pickableMeshes())
}
return targets
}
private pickHandle(event: PointerEvent): DragHandleType | null {
if (!this.gizmosOn || this.lookingThrough) return null
const targets = this.pickableTargetsFor(this.overlay.getState().mode)
if (targets.length === 0) return null
this.updatePointer(event)
return pickHandleAtPointer<DragHandleType>(
this.raycaster,
this.pointer,
this.viewport.cameraManager.activeCamera,
targets,
this.canvas
)
}
private setHoveredHandle(type: DragHandleType | null): void {
if (this.hoveredHandle === type) return
this.hoveredHandle = type
this.orbitHandles.setHovered(type === 'roll' ? null : type)
this.rollHandle.setHovered(type === 'roll')
this.canvas.style.cursor = type ? 'grab' : ''
this.viewport.forceRender()
}
private readonly onPointerDown = (event: PointerEvent): void => {
if (event.button !== 0) return
if (this.lookingThrough) {
this.lookThroughDrag = {
pointerId: event.pointerId,
lastX: event.clientX,
lastY: event.clientY
}
this.canvas.setPointerCapture(event.pointerId)
this.canvas.style.cursor = 'grabbing'
event.stopPropagation()
return
}
const type = this.pickHandle(event)
if (!type) return
this.setHoveredHandle(type)
this.dragState = { type, pointerId: event.pointerId }
this.canvas.setPointerCapture(event.pointerId)
this.canvas.style.cursor = 'grabbing'
this.viewport.controlsManager.controls.enabled = false
event.stopPropagation()
}
private readonly onPointerMove = (event: PointerEvent): void => {
if (this.lookThroughDrag) {
if (event.pointerId !== this.lookThroughDrag.pointerId) return
const dx = event.clientX - this.lookThroughDrag.lastX
const dy = event.clientY - this.lookThroughDrag.lastY
this.lookThroughDrag.lastX = event.clientX
this.lookThroughDrag.lastY = event.clientY
this.queueRotation(dx, dy)
return
}
if (!this.dragState) {
this.setHoveredHandle(this.pickHandle(event))
return
}
if (event.pointerId !== this.dragState.pointerId) return
this.updatePointer(event)
this.raycaster.setFromCamera(
this.pointer,
this.viewport.cameraManager.activeCamera
)
const state = this.overlay.getState()
const plane =
this.dragState.type === 'roll'
? this.rollHandle.dragPlane(state)
: this.orbitHandles.dragPlaneFor(this.dragState.type, state)
const point = new THREE.Vector3()
if (!this.raycaster.ray.intersectPlane(plane, point)) return
const { fieldName, value, nextState } = computeNextState(
this.dragState.type,
state,
point
)
this.applyState(nextState)
this.onHandleDrag?.(fieldName, value)
}
private readonly onPointerUp = (event: PointerEvent): void => {
if (this.lookThroughDrag) {
if (event.pointerId !== this.lookThroughDrag.pointerId) return
if (this.canvas.hasPointerCapture(event.pointerId)) {
this.canvas.releasePointerCapture(event.pointerId)
}
this.lookThroughDrag = null
this.flushInput()
this.cancelInputFrame()
this.canvas.style.cursor = ''
return
}
if (!this.dragState || event.pointerId !== this.dragState.pointerId) return
if (this.canvas.hasPointerCapture(event.pointerId)) {
this.canvas.releasePointerCapture(event.pointerId)
}
this.dragState = null
this.viewport.controlsManager.controls.enabled = true
this.canvas.style.cursor = this.hoveredHandle ? 'grab' : ''
}
private queueRotation(dx: number, dy: number): void {
this.pendingRotation = {
dx: (this.pendingRotation?.dx ?? 0) + dx,
dy: (this.pendingRotation?.dy ?? 0) + dy
}
this.scheduleInputFrame()
}
private queueDolly(deltaY: number): void {
this.pendingDolly = (this.pendingDolly ?? 0) + deltaY
this.scheduleInputFrame()
}
private scheduleInputFrame(): void {
if (this.inputFrame !== null) return
this.inputFrame = requestAnimationFrame(() => {
this.inputFrame = null
this.flushInput()
})
}
private flushInput(): void {
const rotation = this.pendingRotation
const dolly = this.pendingDolly
this.pendingRotation = null
this.pendingDolly = null
if (rotation) {
this.applyResult(
rotateSubjectByDrag(
this.overlay.getState(),
-rotation.dx * LOOK_THROUGH_SENSITIVITY,
-rotation.dy * LOOK_THROUGH_SENSITIVITY
)
)
}
if (dolly !== null) {
this.applyResult(dollySubjectByWheel(this.overlay.getState(), dolly))
}
}
private applyResult(result: LookThroughResult | null): void {
if (!result) return
this.applyState(result.nextState)
for (const update of result.updates) {
this.onHandleDrag?.(update.fieldName, update.value)
}
}
private cancelInputFrame(): void {
if (this.inputFrame !== null) {
cancelAnimationFrame(this.inputFrame)
this.inputFrame = null
}
this.pendingRotation = null
this.pendingDolly = null
}
private onPostRender(): void {
if (this.lookingThrough) {
if (this.fitSubjectAspect()) this.viewport.forceRender()
return
}
this.renderSubjectCameraPreview()
}
private fitSubjectAspect(): boolean {
const canvas = this.viewport.domElement
const aspect = canvas.width / canvas.height
if (!Number.isFinite(aspect) || aspect <= 0) return false
const cam = this.overlay.getSubjectCamera()
if (cam instanceof THREE.PerspectiveCamera) {
if (Math.abs(cam.aspect - aspect) < 1e-4) return false
cam.aspect = aspect
cam.updateProjectionMatrix()
return true
}
if (cam instanceof THREE.OrthographicCamera) {
const half = (cam.top - cam.bottom) / 2 || 1
const left = -half * aspect
const right = half * aspect
if (
Math.abs(cam.left - left) < 1e-4 &&
Math.abs(cam.right - right) < 1e-4
)
return false
cam.left = left
cam.right = right
cam.updateProjectionMatrix()
return true
}
return false
}
private renderSubjectCameraPreview(): void {
const renderer = this.viewport.renderer
const canvas = this.viewport.domElement
const canvasWidth = canvas.width
const canvasHeight = canvas.height
if (
canvasWidth < PREVIEW_WIDTH + PREVIEW_PADDING * 2 ||
canvasHeight < PREVIEW_HEIGHT + PREVIEW_PADDING * 2
) {
return
}
const cam = this.overlay.getSubjectCamera()
const aspect = PREVIEW_WIDTH / PREVIEW_HEIGHT
let savedAspect: number | undefined
let savedOrtho:
| {
left: number
right: number
top: number
bottom: number
}
| undefined
if (cam instanceof THREE.PerspectiveCamera) {
savedAspect = cam.aspect
cam.aspect = aspect
cam.updateProjectionMatrix()
} else if (cam instanceof THREE.OrthographicCamera) {
savedOrtho = {
left: cam.left,
right: cam.right,
top: cam.top,
bottom: cam.bottom
}
const half = (cam.top - cam.bottom) / 2 || 1
cam.left = -half * aspect
cam.right = half * aspect
cam.top = half
cam.bottom = -half
cam.updateProjectionMatrix()
}
this.overlay.setHelperVisible(false)
const handlesWereVisible = this.orbitHandles.isVisible()
const rollWasVisible = this.rollHandle.isVisible()
const targetWasVisible = this.targetHandle.isVisible()
const cameraWasVisible = this.cameraHandle.isVisible()
this.orbitHandles.setVisible(false)
this.rollHandle.setVisible(false)
this.targetHandle.setVisible(false)
this.cameraHandle.setVisible(false)
const x = canvasWidth - PREVIEW_WIDTH - PREVIEW_PADDING
const y = PREVIEW_PADDING
renderer.setViewport(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
renderer.setScissor(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
renderer.setScissorTest(true)
renderer.setClearColor(PREVIEW_BORDER_COLOR)
renderer.clear()
renderer.setViewport(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
renderer.setScissor(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
renderer.setClearColor(0x0a0a0a)
renderer.clear()
renderer.render(this.viewport.sceneManager.scene, cam)
this.overlay.setHelperVisible(true)
if (handlesWereVisible) this.orbitHandles.setVisible(true)
if (rollWasVisible) this.rollHandle.setVisible(true)
if (targetWasVisible) this.targetHandle.setVisible(true)
if (cameraWasVisible) this.cameraHandle.setVisible(true)
if (savedAspect !== undefined && cam instanceof THREE.PerspectiveCamera) {
cam.aspect = savedAspect
cam.updateProjectionMatrix()
} else if (savedOrtho && cam instanceof THREE.OrthographicCamera) {
cam.left = savedOrtho.left
cam.right = savedOrtho.right
cam.top = savedOrtho.top
cam.bottom = savedOrtho.bottom
cam.updateProjectionMatrix()
}
}
}
interface OrbitDragResult {
fieldName: CameraInfoFieldName
value: number
nextState: CameraInfoState
}
function computeNextState(
type: DragHandleType,
state: CameraInfoState,
point: THREE.Vector3
): OrbitDragResult {
if (type === 'roll') {
const cameraPos = computeSubjectTransform(state).position
const value = pointToRollAngle(
{ x: point.x, y: point.y, z: point.z },
state.target,
{ x: cameraPos.x, y: cameraPos.y, z: cameraPos.z }
)
return {
fieldName: 'roll',
value,
nextState: { ...state, roll: value }
}
}
const fieldName = FIELD_NAME_FOR[type]
if (type === 'yaw') {
const value = pointToYawAngle(point, state.target)
return {
fieldName,
value,
nextState: { ...state, orbit: { ...state.orbit, yaw: value } }
}
}
if (type === 'pitch') {
const value = pointToPitchAngle(point, state.target, state.orbit.yaw)
return {
fieldName,
value,
nextState: { ...state, orbit: { ...state.orbit, pitch: value } }
}
}
const value = pointToDistance(
point,
state.target,
state.orbit.yaw,
state.orbit.pitch
)
return {
fieldName,
value,
nextState: { ...state, orbit: { ...state.orbit, distance: value } }
}
}
function targetApplies(mode: CameraInfoMode): boolean {
return mode === 'orbit' || mode === 'look_at'
}
function cameraTranslateApplies(mode: CameraInfoMode): boolean {
return mode === 'look_at' || mode === 'quaternion'
}
function cameraRotateApplies(mode: CameraInfoMode): boolean {
return mode === 'quaternion'
}
function rollApplies(mode: CameraInfoMode): boolean {
return mode === 'orbit' || mode === 'look_at'
}
function nextStateForCameraDrag(
state: CameraInfoState,
transform: CameraHandleTransform,
mode: CameraHandleMode
): CameraInfoState {
const { position, quaternion } = transform
if (mode === 'translate') {
if (state.mode === 'look_at') {
return { ...state, lookAt: { position: { ...position } } }
}
if (state.mode === 'quaternion') {
return {
...state,
quaternion: { ...state.quaternion, position: { ...position } }
}
}
return state
}
if (state.mode === 'quaternion') {
return {
...state,
quaternion: { ...state.quaternion, quat: { ...quaternion } }
}
}
return state
}

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { computeSubjectTransform } from './cameraTransform'
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
function withMode(
mode: CameraInfoState['mode'],
overrides: Partial<CameraInfoState> = {}
): CameraInfoState {
return { ...DEFAULT_CAMERA_INFO_STATE, mode, ...overrides }
}
describe('computeSubjectTransform', () => {
describe('orbit mode', () => {
it('positions the camera at target + distance along yaw/pitch', () => {
const state = withMode('orbit', {
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(0)
expect(position.y).toBeCloseTo(0)
expect(position.z).toBeCloseTo(5)
})
it('honors yaw=90deg (camera goes to +X)', () => {
const state = withMode('orbit', {
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 90, pitch: 0, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(5)
expect(position.y).toBeCloseTo(0)
expect(position.z).toBeCloseTo(0)
})
it('honors pitch=90deg (camera goes straight up)', () => {
const state = withMode('orbit', {
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 0, pitch: 90, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(0)
expect(position.y).toBeCloseTo(5)
expect(position.z).toBeCloseTo(0, 5)
})
it('offsets the orbit by target', () => {
const state = withMode('orbit', {
target: { x: 10, y: 20, z: 30 },
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const { position } = computeSubjectTransform(state)
expect(position.x).toBeCloseTo(10)
expect(position.y).toBeCloseTo(20)
expect(position.z).toBeCloseTo(35)
})
})
describe('look_at mode', () => {
it('uses explicit position', () => {
const state = withMode('look_at', {
lookAt: { position: { x: 1, y: 2, z: 3 } }
})
const { position } = computeSubjectTransform(state)
expect(position.toArray()).toEqual([1, 2, 3])
})
})
describe('quaternion mode', () => {
it('uses explicit position and quaternion (target ignored)', () => {
const state = withMode('quaternion', {
target: { x: 999, y: 999, z: 999 },
quaternion: {
position: { x: 7, y: 8, z: 9 },
quat: { x: 0, y: 0, z: 0, w: 1 }
}
})
const { position, quaternion } = computeSubjectTransform(state)
expect(position.toArray()).toEqual([7, 8, 9])
expect(quaternion.x).toBeCloseTo(0)
expect(quaternion.y).toBeCloseTo(0)
expect(quaternion.z).toBeCloseTo(0)
expect(quaternion.w).toBeCloseTo(1)
})
it('normalizes a non-unit quaternion', () => {
const state = withMode('quaternion', {
quaternion: {
position: { x: 0, y: 0, z: 0 },
quat: { x: 2, y: 0, z: 0, w: 0 }
}
})
const { quaternion } = computeSubjectTransform(state)
expect(quaternion.length()).toBeCloseTo(1)
})
it('falls back to identity when given a zero quaternion', () => {
const state = withMode('quaternion', {
quaternion: {
position: { x: 0, y: 0, z: 0 },
quat: { x: 0, y: 0, z: 0, w: 0 }
}
})
const { quaternion } = computeSubjectTransform(state)
expect(quaternion.x).toBe(0)
expect(quaternion.y).toBe(0)
expect(quaternion.z).toBe(0)
expect(quaternion.w).toBe(1)
})
})
describe('roll', () => {
it('rotates around the view axis without moving the camera position', () => {
const base = withMode('look_at', {
target: { x: 0, y: 0, z: 0 },
lookAt: { position: { x: 0, y: 0, z: 5 } },
roll: 0
})
const rolled = { ...base, roll: 45 }
const a = computeSubjectTransform(base)
const b = computeSubjectTransform(rolled)
expect(a.position.toArray()).toEqual(b.position.toArray())
expect(a.quaternion.equals(b.quaternion)).toBe(false)
})
})
})

View File

@@ -0,0 +1,89 @@
import * as THREE from 'three'
import type { CameraInfoState } from './types'
const DEG2RAD = Math.PI / 180
export interface SubjectCameraTransform {
position: THREE.Vector3
quaternion: THREE.Quaternion
}
function orbitPosition(
target: THREE.Vector3Like,
yawDeg: number,
pitchDeg: number,
distance: number
): THREE.Vector3 {
const y = yawDeg * DEG2RAD
const p = pitchDeg * DEG2RAD
const cp = Math.cos(p)
return new THREE.Vector3(
target.x + distance * cp * Math.sin(y),
target.y + distance * Math.sin(p),
target.z + distance * cp * Math.cos(y)
)
}
function lookAtQuaternion(
position: THREE.Vector3,
target: THREE.Vector3Like,
rollDeg: number
): THREE.Quaternion {
const targetVec = new THREE.Vector3(target.x, target.y, target.z)
const m = new THREE.Matrix4().lookAt(
position,
targetVec,
new THREE.Vector3(0, 1, 0)
)
const q = new THREE.Quaternion().setFromRotationMatrix(m)
if (rollDeg !== 0) {
const rollQ = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 0, 1),
rollDeg * DEG2RAD
)
q.multiply(rollQ)
}
return q
}
export function normalizeQuaternion(q: THREE.Quaternion): THREE.Quaternion {
if (q.lengthSq() === 0) q.set(0, 0, 0, 1)
else q.normalize()
return q
}
export function computeSubjectTransform(
state: CameraInfoState
): SubjectCameraTransform {
if (state.mode === 'quaternion') {
const p = state.quaternion.position
const q = state.quaternion.quat
const quaternion = normalizeQuaternion(
new THREE.Quaternion(q.x, q.y, q.z, q.w)
)
return {
position: new THREE.Vector3(p.x, p.y, p.z),
quaternion
}
}
const position =
state.mode === 'orbit'
? orbitPosition(
state.target,
state.orbit.yaw,
state.orbit.pitch,
state.orbit.distance
)
: new THREE.Vector3(
state.lookAt.position.x,
state.lookAt.position.y,
state.lookAt.position.z
)
return {
position,
quaternion: lookAtQuaternion(position, state.target, state.roll)
}
}

View File

@@ -0,0 +1,103 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CameraHandle,
type CameraHandleMode,
type CameraHandleTransform
} from './CameraHandle'
describe('CameraHandle', () => {
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let dom: HTMLElement
let onDragging: (dragging: boolean) => void
let onChange: (
transform: CameraHandleTransform,
mode: CameraHandleMode
) => void
let onChangeMock: ReturnType<typeof vi.fn>
let handle: CameraHandle
beforeEach(() => {
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera()
dom = document.createElement('div')
onDragging = vi.fn() as unknown as (dragging: boolean) => void
onChangeMock = vi.fn()
onChange = onChangeMock as unknown as (
transform: CameraHandleTransform,
mode: CameraHandleMode
) => void
handle = new CameraHandle(camera, dom, onDragging, onChange)
handle.attach(scene)
})
afterEach(() => {
handle.dispose()
})
it('adds a proxy + a helper to the scene on attach', () => {
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
).toBeDefined()
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
).toBeDefined()
})
it('starts hidden with controls disabled', () => {
expect(handle.isVisible()).toBe(false)
})
it('defaults to translate mode', () => {
expect(handle.getMode()).toBe('translate')
})
it('setMode switches translate <-> rotate', () => {
handle.setMode('rotate')
expect(handle.getMode()).toBe('rotate')
handle.setMode('translate')
expect(handle.getMode()).toBe('translate')
})
it('setSubject moves the proxy to the given pose without firing onChange', () => {
handle.setSubject({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0, w: 1 })
const proxy = scene.children.find(
(c) => c.name === 'CameraInfoCameraProxy'
)!
expect(proxy.position.x).toBe(1)
expect(proxy.position.y).toBe(2)
expect(proxy.position.z).toBe(3)
expect(onChangeMock).not.toHaveBeenCalled()
})
it('setSubject is a no-op when the pose already matches', () => {
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
onChangeMock.mockClear()
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
expect(onChangeMock).not.toHaveBeenCalled()
})
it('setVisible toggles helper.visible and controls.enabled together', () => {
handle.setVisible(true)
expect(handle.isVisible()).toBe(true)
handle.setVisible(false)
expect(handle.isVisible()).toBe(false)
})
it('dispose removes proxy and helper from the scene', () => {
handle.dispose()
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
).toBeUndefined()
expect(
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
).toBeUndefined()
})
})

View File

@@ -0,0 +1,149 @@
import * as THREE from 'three'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import type { DraggingChangeListener } from './types'
export type CameraHandleMode = 'translate' | 'rotate'
export interface CameraHandleTransform {
position: THREE.Vector3Like
quaternion: { x: number; y: number; z: number; w: number }
}
type ChangeListener = (
transform: CameraHandleTransform,
mode: CameraHandleMode
) => void
export class CameraHandle {
private readonly proxy: THREE.Object3D
private readonly controls: TransformControls
private readonly helper: THREE.Object3D
private mode: CameraHandleMode = 'translate'
private suppressEcho = false
private scene: THREE.Scene | null = null
private disposed = false
constructor(
camera: THREE.Camera,
domElement: HTMLElement,
private readonly onDraggingChange: DraggingChangeListener,
private readonly onChange: ChangeListener
) {
this.proxy = new THREE.Object3D()
this.proxy.name = 'CameraInfoCameraProxy'
this.controls = new TransformControls(camera, domElement)
this.controls.setMode(this.mode)
this.controls.setSize(0.8)
this.controls.setSpace(spaceFor(this.mode))
this.controls.attach(this.proxy)
this.helper = this.controls.getHelper()
this.helper.name = 'CameraInfoCameraHandle'
this.helper.visible = false
this.controls.enabled = false
this.controls.addEventListener('dragging-changed', this.onDragging)
this.controls.addEventListener('objectChange', this.onObjectChange)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.proxy)
scene.add(this.helper)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.proxy)
this.scene.remove(this.helper)
this.scene = null
}
setVisible(visible: boolean): void {
this.helper.visible = visible
this.controls.enabled = visible
}
isVisible(): boolean {
return this.helper.visible
}
setMode(mode: CameraHandleMode): void {
if (this.mode === mode) return
this.mode = mode
this.controls.setMode(mode)
this.controls.setSpace(spaceFor(mode))
}
getMode(): CameraHandleMode {
return this.mode
}
setSubject(
position: THREE.Vector3Like,
quaternion: { x: number; y: number; z: number; w: number }
): void {
const samePosition =
this.proxy.position.x === position.x &&
this.proxy.position.y === position.y &&
this.proxy.position.z === position.z
const sameQuaternion =
this.proxy.quaternion.x === quaternion.x &&
this.proxy.quaternion.y === quaternion.y &&
this.proxy.quaternion.z === quaternion.z &&
this.proxy.quaternion.w === quaternion.w
if (samePosition && sameQuaternion) return
this.suppressEcho = true
try {
this.proxy.position.set(position.x, position.y, position.z)
this.proxy.quaternion.set(
quaternion.x,
quaternion.y,
quaternion.z,
quaternion.w
)
this.proxy.updateMatrixWorld(true)
} finally {
this.suppressEcho = false
}
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.controls.removeEventListener('dragging-changed', this.onDragging)
this.controls.removeEventListener('objectChange', this.onObjectChange)
this.controls.detach()
this.detach()
this.controls.dispose()
}
private readonly onDragging = (event: { value: unknown }): void => {
this.onDraggingChange(event.value === true)
}
private readonly onObjectChange = (): void => {
if (this.suppressEcho) return
this.onChange(
{
position: {
x: this.proxy.position.x,
y: this.proxy.position.y,
z: this.proxy.position.z
},
quaternion: {
x: this.proxy.quaternion.x,
y: this.proxy.quaternion.y,
z: this.proxy.quaternion.z,
w: this.proxy.quaternion.w
}
},
this.mode
)
}
}
function spaceFor(mode: CameraHandleMode): 'world' | 'local' {
return mode === 'translate' ? 'world' : 'local'
}

View File

@@ -0,0 +1,108 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
import { OrbitHandles } from './OrbitHandles'
describe('OrbitHandles', () => {
let scene: THREE.Scene
let handles: OrbitHandles
beforeEach(() => {
scene = new THREE.Scene()
handles = new OrbitHandles()
handles.attach(scene)
})
afterEach(() => {
handles.dispose()
})
it('adds a single root group to the scene on attach', () => {
const root = scene.children.find((c) => c.name === 'CameraInfoOrbitHandles')
expect(root).toBeDefined()
})
it('exposes three pickable handle meshes tagged with handleType', () => {
const meshes = handles.pickableMeshes()
expect(meshes).toHaveLength(3)
const types = meshes.map((m) => m.userData.handleType).sort()
expect(types).toEqual(['distance', 'pitch', 'yaw'])
})
it('positions the yaw handle on the +Z side of the ring at yaw=0', () => {
handles.update({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
const yawHandle = handles
.pickableMeshes()
.find((m) => m.userData.handleType === 'yaw')!
expect(yawHandle.position.x).toBeCloseTo(0)
expect(yawHandle.position.z).toBeGreaterThan(0)
})
it('places the distance handle at the camera (distance along yaw direction)', () => {
handles.update({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
target: { x: 0, y: 0, z: 0 },
orbit: { yaw: 90, pitch: 0, distance: 7 }
})
const distanceHandle = handles
.pickableMeshes()
.find((m) => m.userData.handleType === 'distance')!
const world = new THREE.Vector3()
distanceHandle.getWorldPosition(world)
expect(world.x).toBeCloseTo(7)
expect(world.y).toBeCloseTo(0)
expect(world.z).toBeCloseTo(0)
})
it('hides itself in non-orbit modes', () => {
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
const root = scene.children.find(
(c) => c.name === 'CameraInfoOrbitHandles'
)!
expect(root.visible).toBe(false)
})
it('setVisible forces visibility independent of mode', () => {
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
handles.setVisible(false)
const root = scene.children.find(
(c) => c.name === 'CameraInfoOrbitHandles'
)!
expect(root.visible).toBe(false)
})
it('isVisible reflects the root group, not individual handle meshes', () => {
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
expect(handles.isVisible()).toBe(false)
expect(handles.pickableMeshes()[0].visible).toBe(true)
})
it('yaw drag plane is horizontal through target', () => {
const plane = handles.dragPlaneFor('yaw', {
...DEFAULT_CAMERA_INFO_STATE,
target: { x: 0, y: 3, z: 0 }
})
expect(plane.normal.y).toBeCloseTo(1)
expect(plane.constant).toBeCloseTo(-3)
})
it('pitch drag plane is vertical and orthogonal to yaw direction', () => {
const plane = handles.dragPlaneFor('pitch', {
...DEFAULT_CAMERA_INFO_STATE,
orbit: { yaw: 0, pitch: 0, distance: 5 }
})
expect(Math.abs(plane.normal.x)).toBeCloseTo(1)
expect(plane.normal.y).toBeCloseTo(0)
})
})

View File

@@ -0,0 +1,258 @@
import * as THREE from 'three'
import type { CameraInfoState } from '../types'
const RING_RADIUS = 1.5
const HANDLE_RADIUS = 0.08
const GLOW_RADIUS = 0.12
const TUBE_RADIUS = 0.025
const ARC_PITCH_LIMIT = 85
const ARC_SEGMENTS = 32
const YAW_COLOR = 0x00d4ff
const PITCH_COLOR = 0xff66ff
const DISTANCE_COLOR = 0xffcc00
const BASE_GLOW_OPACITY = 0.25
const HOVER_GLOW_OPACITY = 0.6
const HOVER_SCALE = 1.35
export type OrbitHandleType = 'yaw' | 'pitch' | 'distance'
const DEG2RAD = Math.PI / 180
function buildArcCurve(): THREE.CatmullRomCurve3 {
const points: THREE.Vector3[] = []
for (let deg = -ARC_PITCH_LIMIT; deg <= ARC_PITCH_LIMIT; deg += 5) {
const r = deg * DEG2RAD
points.push(
new THREE.Vector3(0, RING_RADIUS * Math.sin(r), RING_RADIUS * Math.cos(r))
)
}
return new THREE.CatmullRomCurve3(points)
}
function makeHandle(color: number, type: OrbitHandleType): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.7,
roughness: 0.3,
metalness: 0.2
})
)
mesh.userData.handleType = type
return mesh
}
function makeGlow(color: number): THREE.Mesh {
return new THREE.Mesh(
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: BASE_GLOW_OPACITY,
depthWrite: false,
blending: THREE.AdditiveBlending
})
)
}
export class OrbitHandles {
private readonly root = new THREE.Group()
private readonly yawGroup = new THREE.Group()
private readonly yawRing: THREE.Mesh
private readonly pitchArc: THREE.Mesh
private readonly distanceLine: THREE.Line
private readonly distanceLineGeometry: THREE.BufferGeometry
private readonly yawHandle: THREE.Mesh
private readonly pitchHandle: THREE.Mesh
private readonly distanceHandle: THREE.Mesh
private readonly yawHandleGlow: THREE.Mesh
private readonly pitchHandleGlow: THREE.Mesh
private readonly distanceHandleGlow: THREE.Mesh
private scene: THREE.Scene | null = null
private disposed = false
constructor() {
this.root.name = 'CameraInfoOrbitHandles'
this.root.add(this.yawGroup)
this.yawRing = new THREE.Mesh(
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 96),
new THREE.MeshBasicMaterial({
color: YAW_COLOR,
transparent: true,
opacity: 0.55
})
)
this.yawRing.rotation.x = Math.PI / 2
this.root.add(this.yawRing)
this.pitchArc = new THREE.Mesh(
new THREE.TubeGeometry(
buildArcCurve(),
ARC_SEGMENTS,
TUBE_RADIUS,
8,
false
),
new THREE.MeshBasicMaterial({
color: PITCH_COLOR,
transparent: true,
opacity: 0.55
})
)
this.yawGroup.add(this.pitchArc)
this.distanceLineGeometry = new THREE.BufferGeometry()
this.distanceLineGeometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 0, 0, 1], 3)
)
this.distanceLine = new THREE.Line(
this.distanceLineGeometry,
new THREE.LineBasicMaterial({
color: DISTANCE_COLOR,
transparent: true,
opacity: 0.55
})
)
this.yawGroup.add(this.distanceLine)
this.yawHandle = makeHandle(YAW_COLOR, 'yaw')
this.yawHandleGlow = makeGlow(YAW_COLOR)
this.yawHandle.add(this.yawHandleGlow)
this.root.add(this.yawHandle)
this.pitchHandle = makeHandle(PITCH_COLOR, 'pitch')
this.pitchHandleGlow = makeGlow(PITCH_COLOR)
this.pitchHandle.add(this.pitchHandleGlow)
this.yawGroup.add(this.pitchHandle)
this.distanceHandle = makeHandle(DISTANCE_COLOR, 'distance')
this.distanceHandleGlow = makeGlow(DISTANCE_COLOR)
this.distanceHandle.add(this.distanceHandleGlow)
this.yawGroup.add(this.distanceHandle)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.root)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.root)
this.scene = null
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.detach()
const disposables: { dispose: () => void }[] = [
this.yawRing.geometry,
this.yawRing.material as THREE.Material,
this.pitchArc.geometry,
this.pitchArc.material as THREE.Material,
this.distanceLineGeometry,
this.distanceLine.material as THREE.Material,
this.yawHandle.geometry,
this.yawHandle.material as THREE.Material,
this.pitchHandle.geometry,
this.pitchHandle.material as THREE.Material,
this.distanceHandle.geometry,
this.distanceHandle.material as THREE.Material,
this.yawHandleGlow.geometry,
this.yawHandleGlow.material as THREE.Material,
this.pitchHandleGlow.geometry,
this.pitchHandleGlow.material as THREE.Material,
this.distanceHandleGlow.geometry,
this.distanceHandleGlow.material as THREE.Material
]
for (const d of disposables) d.dispose()
}
setVisible(visible: boolean): void {
this.root.visible = visible
}
isVisible(): boolean {
return this.root.visible
}
update(state: CameraInfoState): void {
const visible = state.mode === 'orbit'
this.root.visible = visible
if (!visible) return
this.root.position.set(state.target.x, state.target.y, state.target.z)
const yawRad = state.orbit.yaw * DEG2RAD
const pitchRad = state.orbit.pitch * DEG2RAD
const cosP = Math.cos(pitchRad)
const sinP = Math.sin(pitchRad)
this.yawGroup.rotation.y = yawRad
this.yawHandle.position.set(
RING_RADIUS * Math.sin(yawRad),
0,
RING_RADIUS * Math.cos(yawRad)
)
this.pitchHandle.position.set(0, RING_RADIUS * sinP, RING_RADIUS * cosP)
const endY = state.orbit.distance * sinP
const endZ = state.orbit.distance * cosP
this.distanceHandle.position.set(0, endY, endZ)
const positions = this.distanceLineGeometry.attributes
.position as THREE.BufferAttribute
positions.setXYZ(1, 0, endY, endZ)
positions.needsUpdate = true
}
pickableMeshes(): THREE.Object3D[] {
return [this.yawHandle, this.pitchHandle, this.distanceHandle]
}
setHovered(type: OrbitHandleType | null): void {
const entries: [THREE.Mesh, THREE.Mesh, OrbitHandleType][] = [
[this.yawHandle, this.yawHandleGlow, 'yaw'],
[this.pitchHandle, this.pitchHandleGlow, 'pitch'],
[this.distanceHandle, this.distanceHandleGlow, 'distance']
]
for (const [handle, glow, handleType] of entries) {
const active = handleType === type
handle.scale.setScalar(active ? HOVER_SCALE : 1)
;(glow.material as THREE.MeshBasicMaterial).opacity = active
? HOVER_GLOW_OPACITY
: BASE_GLOW_OPACITY
}
}
dragPlaneFor(type: OrbitHandleType, state: CameraInfoState): THREE.Plane {
if (type === 'yaw') {
return new THREE.Plane(new THREE.Vector3(0, 1, 0), -state.target.y)
}
const yawRad = state.orbit.yaw * DEG2RAD
const normal = new THREE.Vector3(
-Math.cos(yawRad),
0,
Math.sin(yawRad)
).normalize()
const targetVec = vec(state.target)
return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, targetVec)
}
}
const vec = (v: THREE.Vector3Like): THREE.Vector3 =>
new THREE.Vector3(v.x, v.y, v.z)

View File

@@ -0,0 +1,78 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
import { RollHandle } from './RollHandle'
describe('RollHandle', () => {
let scene: THREE.Scene
let handle: RollHandle
beforeEach(() => {
scene = new THREE.Scene()
handle = new RollHandle()
handle.attach(scene)
})
afterEach(() => {
handle.dispose()
})
it('attaches a single root group to the scene', () => {
expect(
scene.children.find((c) => c.name === 'CameraInfoRollHandle')
).toBeDefined()
})
it('exposes one pickable mesh tagged handleType="roll"', () => {
const meshes = handle.pickableMeshes()
expect(meshes).toHaveLength(1)
expect(meshes[0].userData.handleType).toBe('roll')
})
it('hides itself in quaternion mode (rotate gizmo owns roll there)', () => {
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
expect(handle.isVisible()).toBe(false)
})
it('is visible in orbit and look_at modes', () => {
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
expect(handle.isVisible()).toBe(true)
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
expect(handle.isVisible()).toBe(true)
})
it('setVisible forces visibility', () => {
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
handle.setVisible(false)
expect(handle.isVisible()).toBe(false)
})
it('positions itself at the target', () => {
handle.update({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'orbit',
target: { x: 1, y: 2, z: 3 }
})
const root = scene.children.find((c) => c.name === 'CameraInfoRollHandle')!
expect(root.position.x).toBeCloseTo(1)
expect(root.position.y).toBeCloseTo(2)
expect(root.position.z).toBeCloseTo(3)
})
it('drag plane normal points along the camera→target backward direction', () => {
const plane = handle.dragPlane({
...DEFAULT_CAMERA_INFO_STATE,
mode: 'look_at',
target: { x: 0, y: 0, z: 0 },
lookAt: { position: { x: 0, y: 0, z: 5 } }
})
expect(plane.normal.x).toBeCloseTo(0)
expect(plane.normal.y).toBeCloseTo(0)
expect(Math.abs(plane.normal.z)).toBeCloseTo(1)
})
})

View File

@@ -0,0 +1,160 @@
import * as THREE from 'three'
import { computeSubjectTransform } from '../cameraTransform'
import type { CameraInfoState } from '../types'
import { rollBasis } from './rollDragMath'
const RING_RADIUS = 0.9
const HANDLE_RADIUS = 0.08
const GLOW_RADIUS = 0.12
const TUBE_RADIUS = 0.025
const ROLL_COLOR = 0xff8800
const BASE_GLOW_OPACITY = 0.25
const HOVER_GLOW_OPACITY = 0.6
const HOVER_SCALE = 1.35
const DEG2RAD = Math.PI / 180
function makeHandle(): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
new THREE.MeshStandardMaterial({
color: ROLL_COLOR,
emissive: ROLL_COLOR,
emissiveIntensity: 0.7,
roughness: 0.3,
metalness: 0.2
})
)
mesh.userData.handleType = 'roll'
return mesh
}
export class RollHandle {
private readonly root = new THREE.Group()
private readonly ring: THREE.Mesh
private readonly handle: THREE.Mesh
private readonly handleGlow: THREE.Mesh
private scene: THREE.Scene | null = null
private disposed = false
constructor() {
this.root.name = 'CameraInfoRollHandle'
this.ring = new THREE.Mesh(
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 80),
new THREE.MeshBasicMaterial({
color: ROLL_COLOR,
transparent: true,
opacity: 0.55
})
)
this.root.add(this.ring)
this.handle = makeHandle()
this.handleGlow = new THREE.Mesh(
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
new THREE.MeshBasicMaterial({
color: ROLL_COLOR,
transparent: true,
opacity: BASE_GLOW_OPACITY,
depthWrite: false,
blending: THREE.AdditiveBlending
})
)
this.handle.add(this.handleGlow)
this.root.add(this.handle)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.root)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.root)
this.scene = null
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.detach()
const disposables: { dispose: () => void }[] = [
this.ring.geometry,
this.ring.material as THREE.Material,
this.handle.geometry,
this.handle.material as THREE.Material,
this.handleGlow.geometry,
this.handleGlow.material as THREE.Material
]
for (const d of disposables) d.dispose()
}
setVisible(visible: boolean): void {
this.root.visible = visible
}
isVisible(): boolean {
return this.root.visible
}
update(state: CameraInfoState): void {
if (state.mode === 'quaternion') {
this.root.visible = false
return
}
this.root.visible = true
const { position: cameraPos } = computeSubjectTransform(state)
const cameraVec: THREE.Vector3Like = {
x: cameraPos.x,
y: cameraPos.y,
z: cameraPos.z
}
const { up, right, backward } = rollBasis(state.target, cameraVec)
this.root.position.set(state.target.x, state.target.y, state.target.z)
const orient = new THREE.Quaternion().setFromRotationMatrix(
new THREE.Matrix4().makeBasis(right, up, backward)
)
this.root.quaternion.copy(orient)
const theta = state.roll * DEG2RAD
this.handle.position.set(
RING_RADIUS * Math.sin(theta),
RING_RADIUS * Math.cos(theta),
0
)
}
pickableMeshes(): THREE.Object3D[] {
return [this.handle]
}
setHovered(hovered: boolean): void {
this.handle.scale.setScalar(hovered ? HOVER_SCALE : 1)
;(this.handleGlow.material as THREE.MeshBasicMaterial).opacity = hovered
? HOVER_GLOW_OPACITY
: BASE_GLOW_OPACITY
}
dragPlane(state: CameraInfoState): THREE.Plane {
const { position: cameraPos } = computeSubjectTransform(state)
const cameraVec: THREE.Vector3Like = {
x: cameraPos.x,
y: cameraPos.y,
z: cameraPos.z
}
const { backward } = rollBasis(state.target, cameraVec)
const tgt = new THREE.Vector3(
state.target.x,
state.target.y,
state.target.z
)
return new THREE.Plane().setFromNormalAndCoplanarPoint(backward, tgt)
}
}

View File

@@ -0,0 +1,99 @@
import * as THREE from 'three'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import type { DraggingChangeListener } from './types'
type ChangeListener = (target: THREE.Vector3Like) => void
export class TargetHandle {
private readonly proxy: THREE.Object3D
private readonly controls: TransformControls
private readonly helper: THREE.Object3D
private suppressEcho = false
private scene: THREE.Scene | null = null
private disposed = false
constructor(
camera: THREE.Camera,
domElement: HTMLElement,
private readonly onDraggingChange: DraggingChangeListener,
private readonly onChange: ChangeListener
) {
this.proxy = new THREE.Object3D()
this.proxy.name = 'CameraInfoTargetProxy'
this.controls = new TransformControls(camera, domElement)
this.controls.setMode('translate')
this.controls.setSize(0.8)
this.controls.attach(this.proxy)
this.helper = this.controls.getHelper()
this.helper.name = 'CameraInfoTargetHandle'
this.helper.visible = false
this.controls.enabled = false
this.controls.addEventListener('dragging-changed', this.onDragging)
this.controls.addEventListener('objectChange', this.onObjectChange)
}
attach(scene: THREE.Scene): void {
this.scene = scene
scene.add(this.proxy)
scene.add(this.helper)
}
detach(): void {
if (!this.scene) return
this.scene.remove(this.proxy)
this.scene.remove(this.helper)
this.scene = null
}
setVisible(visible: boolean): void {
this.helper.visible = visible
this.controls.enabled = visible
}
isVisible(): boolean {
return this.helper.visible
}
setTarget(target: THREE.Vector3Like): void {
if (
this.proxy.position.x === target.x &&
this.proxy.position.y === target.y &&
this.proxy.position.z === target.z
) {
return
}
this.suppressEcho = true
try {
this.proxy.position.set(target.x, target.y, target.z)
this.proxy.updateMatrixWorld(true)
} finally {
this.suppressEcho = false
}
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.controls.removeEventListener('dragging-changed', this.onDragging)
this.controls.removeEventListener('objectChange', this.onObjectChange)
this.controls.detach()
this.detach()
this.controls.dispose()
}
private readonly onDragging = (event: { value: unknown }): void => {
this.onDraggingChange(event.value === true)
}
private readonly onObjectChange = (): void => {
if (this.suppressEcho) return
this.onChange({
x: this.proxy.position.x,
y: this.proxy.position.y,
z: this.proxy.position.z
})
}
}

View File

@@ -0,0 +1,102 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it } from 'vitest'
import { pickHandleAtPointer } from './handlePicking'
const CANVAS = { clientWidth: 400, clientHeight: 400 }
function makeHandle(handleType: string, position: THREE.Vector3): THREE.Mesh {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8))
mesh.userData.handleType = handleType
mesh.position.copy(position)
mesh.updateMatrixWorld(true)
return mesh
}
describe('pickHandleAtPointer', () => {
let camera: THREE.PerspectiveCamera
let raycaster: THREE.Raycaster
beforeEach(() => {
camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100)
camera.position.set(0, 0, 10)
camera.lookAt(0, 0, 0)
camera.updateMatrixWorld(true)
camera.updateProjectionMatrix()
raycaster = new THREE.Raycaster()
})
it('returns the handle type on a direct raycast hit', () => {
const handle = makeHandle('yaw', new THREE.Vector3(0, 0, 0))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0, 0),
camera,
[handle],
CANVAS
)
expect(picked).toBe('yaw')
})
it('picks a handle within the screen-space tolerance on a near miss', () => {
const handle = makeHandle('pitch', new THREE.Vector3(0, 0, 0))
// ~10px off centre: outside the 0.08-radius sphere but inside tolerance.
const nearMissNdc = new THREE.Vector2(10 / (CANVAS.clientWidth / 2), 0)
const picked = pickHandleAtPointer(
raycaster,
nearMissNdc,
camera,
[handle],
CANVAS
)
expect(picked).toBe('pitch')
})
it('returns null when the pointer is far from every handle', () => {
const handle = makeHandle('distance', new THREE.Vector3(0, 0, 0))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0.5, 0.5),
camera,
[handle],
CANVAS
)
expect(picked).toBeNull()
})
it('prefers the nearest handle when several are within tolerance', () => {
const near = makeHandle('yaw', new THREE.Vector3(0.05, 0, 5))
const far = makeHandle('pitch', new THREE.Vector3(-0.3, 0, 5))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0.02, 0),
camera,
[far, near],
CANVAS
)
expect(picked).toBe('yaw')
})
it('ignores handles behind the camera', () => {
const behind = makeHandle('yaw', new THREE.Vector3(0, 0, 20))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0, 0),
camera,
[behind],
CANVAS
)
expect(picked).toBeNull()
})
it('returns null with no targets', () => {
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0, 0),
camera,
[],
CANVAS
)
expect(picked).toBeNull()
})
})

View File

@@ -0,0 +1,51 @@
import * as THREE from 'three'
const PICK_PIXEL_RADIUS = 14
interface CanvasSize {
clientWidth: number
clientHeight: number
}
function toScreenPx(
ndcX: number,
ndcY: number,
canvas: CanvasSize
): THREE.Vector2 {
return new THREE.Vector2(
(ndcX + 1) * 0.5 * canvas.clientWidth,
(1 - (ndcY + 1) * 0.5) * canvas.clientHeight
)
}
export function pickHandleAtPointer<T extends string>(
raycaster: THREE.Raycaster,
pointerNdc: THREE.Vector2,
camera: THREE.Camera,
targets: THREE.Object3D[],
canvas: CanvasSize
): T | null {
if (targets.length === 0) return null
raycaster.setFromCamera(pointerNdc, camera)
const hits = raycaster.intersectObjects(targets, false)
if (hits.length > 0) return hits[0].object.userData.handleType as T
const pointerPx = toScreenPx(pointerNdc.x, pointerNdc.y, canvas)
const world = new THREE.Vector3()
let best: T | null = null
let bestDistance = PICK_PIXEL_RADIUS
for (const target of targets) {
target.getWorldPosition(world)
const view = world.clone().applyMatrix4(camera.matrixWorldInverse)
if (view.z >= 0) continue // behind the camera
const ndc = world.project(camera)
const px = toScreenPx(ndc.x, ndc.y, canvas)
const distance = px.distanceTo(pointerPx)
if (distance < bestDistance) {
bestDistance = distance
best = target.userData.handleType as T
}
}
return best
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
MAX_DISTANCE,
MAX_PITCH,
MIN_DISTANCE,
MIN_PITCH,
pointToDistance,
pointToPitchAngle,
pointToYawAngle
} from './orbitDragMath'
const TARGET = { x: 0, y: 0, z: 0 }
const SHIFTED_TARGET = { x: 2, y: 1, z: -3 }
describe('pointToYawAngle', () => {
it('returns 0 when the point sits on +Z relative to the target', () => {
expect(pointToYawAngle({ x: 0, y: 0, z: 5 }, TARGET)).toBeCloseTo(0)
})
it('returns 90 when the point sits on +X (rotation around Y)', () => {
expect(pointToYawAngle({ x: 5, y: 0, z: 0 }, TARGET)).toBeCloseTo(90)
})
it('returns -90 when the point sits on -X', () => {
expect(pointToYawAngle({ x: -5, y: 0, z: 0 }, TARGET)).toBeCloseTo(-90)
})
it('returns 180 (or -180) when the point sits on -Z', () => {
expect(
Math.abs(pointToYawAngle({ x: 0, y: 0, z: -5 }, TARGET))
).toBeCloseTo(180)
})
it('is invariant to the y component of the point and target', () => {
expect(
pointToYawAngle({ x: 3, y: 99, z: 4 }, { x: 0, y: -7, z: 0 })
).toBeCloseTo(pointToYawAngle({ x: 3, y: 0, z: 4 }, TARGET))
})
it('works against a non-origin target', () => {
expect(
pointToYawAngle(
{ x: SHIFTED_TARGET.x, y: 0, z: SHIFTED_TARGET.z + 5 },
SHIFTED_TARGET
)
).toBeCloseTo(0)
})
})
describe('pointToPitchAngle', () => {
it('returns 0 when the point lies in the horizontal plane', () => {
expect(pointToPitchAngle({ x: 0, y: 0, z: 5 }, TARGET, 0)).toBeCloseTo(0)
})
it('returns +45 for a point lifted to (0, h, h) at yaw=0', () => {
expect(pointToPitchAngle({ x: 0, y: 5, z: 5 }, TARGET, 0)).toBeCloseTo(45)
})
it('returns -30 for a downward-pointing intersection at yaw=0', () => {
const h = 5
const v = h * Math.tan((30 * Math.PI) / 180)
expect(pointToPitchAngle({ x: 0, y: -v, z: h }, TARGET, 0)).toBeCloseTo(-30)
})
it('respects the current yaw direction when projecting horizontally', () => {
expect(pointToPitchAngle({ x: 5, y: 5, z: 0 }, TARGET, 90)).toBeCloseTo(45)
})
it('clamps to MAX_PITCH past the upper pole', () => {
expect(pointToPitchAngle({ x: 0, y: 100, z: 0.001 }, TARGET, 0)).toBe(
MAX_PITCH
)
})
it('clamps to MIN_PITCH past the lower pole', () => {
expect(pointToPitchAngle({ x: 0, y: -100, z: 0.001 }, TARGET, 0)).toBe(
MIN_PITCH
)
})
})
describe('pointToDistance', () => {
it('returns the radial projection at yaw=0, pitch=0', () => {
expect(pointToDistance({ x: 0, y: 0, z: 7 }, TARGET, 0, 0)).toBeCloseTo(7)
})
it('returns the radial projection along the current yaw direction', () => {
expect(pointToDistance({ x: 4, y: 0, z: 0 }, TARGET, 90, 0)).toBeCloseTo(4)
})
it('ignores off-axis components of the point', () => {
expect(pointToDistance({ x: 99, y: 0, z: 5 }, TARGET, 0, 0)).toBeCloseTo(5)
})
it('clamps to MIN_DISTANCE when the projection goes behind the target', () => {
expect(pointToDistance({ x: 0, y: 0, z: -10 }, TARGET, 0, 0)).toBe(
MIN_DISTANCE
)
})
it('clamps to MAX_DISTANCE when the projection overshoots', () => {
expect(pointToDistance({ x: 0, y: 0, z: 99999 }, TARGET, 0, 0)).toBe(
MAX_DISTANCE
)
})
})

View File

@@ -0,0 +1,50 @@
import { clamp } from 'es-toolkit'
import type { Vector3Like } from 'three'
const RAD2DEG = 180 / Math.PI
const DEG2RAD = Math.PI / 180
export const MIN_DISTANCE = 0.5
export const MAX_DISTANCE = 100
export const MIN_PITCH = -89
export const MAX_PITCH = 89
export function pointToYawAngle(
point: Vector3Like,
target: Vector3Like
): number {
return Math.atan2(point.x - target.x, point.z - target.z) * RAD2DEG
}
export function pointToPitchAngle(
point: Vector3Like,
target: Vector3Like,
yawDeg: number
): number {
const y = yawDeg * DEG2RAD
const dx = point.x - target.x
const dy = point.y - target.y
const dz = point.z - target.z
const horizontal = dx * Math.sin(y) + dz * Math.cos(y)
const raw = Math.atan2(dy, horizontal) * RAD2DEG
return clamp(raw, MIN_PITCH, MAX_PITCH)
}
export function pointToDistance(
point: Vector3Like,
target: Vector3Like,
yawDeg: number,
pitchDeg: number
): number {
const y = yawDeg * DEG2RAD
const p = pitchDeg * DEG2RAD
const dirX = Math.cos(p) * Math.sin(y)
const dirY = Math.sin(p)
const dirZ = Math.cos(p) * Math.cos(y)
const dx = point.x - target.x
const dy = point.y - target.y
const dz = point.z - target.z
const projection = dx * dirX + dy * dirY + dz * dirZ
return clamp(projection, MIN_DISTANCE, MAX_DISTANCE)
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { pointToRollAngle, rollBasis } from './rollDragMath'
const TARGET = { x: 0, y: 0, z: 0 }
describe('rollBasis', () => {
it('returns world (+X right, +Y up) when the camera looks along -Z', () => {
const basis = rollBasis(TARGET, { x: 0, y: 0, z: 5 })
expect(basis.up.x).toBeCloseTo(0)
expect(basis.up.y).toBeCloseTo(1)
expect(basis.up.z).toBeCloseTo(0)
expect(basis.right.x).toBeCloseTo(1)
expect(basis.right.y).toBeCloseTo(0)
expect(basis.right.z).toBeCloseTo(0)
})
it('falls back to a +X right when the camera looks straight down', () => {
const basis = rollBasis(TARGET, { x: 0, y: 5, z: 0 })
expect(basis.right.x).toBeCloseTo(1)
})
it('orients (up, right) tangent to the camera ray for an oblique view', () => {
const basis = rollBasis(TARGET, { x: 3, y: 4, z: 0 })
expect(basis.right.z).toBeCloseTo(-1)
expect(basis.up.dot(basis.right)).toBeCloseTo(0)
expect(basis.up.dot(basis.backward)).toBeCloseTo(0)
})
})
describe('pointToRollAngle', () => {
const CAMERA = { x: 0, y: 0, z: 5 }
it('returns 0 when the point sits in the +up direction', () => {
expect(pointToRollAngle({ x: 0, y: 1, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
0
)
})
it('returns +90 when the point sits in the +right direction', () => {
expect(pointToRollAngle({ x: 1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
90
)
})
it('returns -90 when the point sits in the -right direction', () => {
expect(pointToRollAngle({ x: -1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
-90
)
})
it('returns 180 (or -180) when the point sits in the -up direction', () => {
expect(
Math.abs(pointToRollAngle({ x: 0, y: -1, z: 0 }, TARGET, CAMERA))
).toBeCloseTo(180)
})
it('is invariant to the off-plane component of the point', () => {
const base = pointToRollAngle({ x: 1, y: 1, z: 0 }, TARGET, CAMERA)
const offPlane = pointToRollAngle({ x: 1, y: 1, z: 7 }, TARGET, CAMERA)
expect(offPlane).toBeCloseTo(base)
})
})

View File

@@ -0,0 +1,40 @@
import * as THREE from 'three'
const RAD2DEG = 180 / Math.PI
export interface RollBasis {
up: THREE.Vector3
right: THREE.Vector3
backward: THREE.Vector3
}
export function rollBasis(
target: THREE.Vector3Like,
cameraPos: THREE.Vector3Like
): RollBasis {
const backward = new THREE.Vector3(
cameraPos.x - target.x,
cameraPos.y - target.y,
cameraPos.z - target.z
).normalize()
const worldUp = new THREE.Vector3(0, 1, 0)
const right = new THREE.Vector3().crossVectors(worldUp, backward)
if (right.lengthSq() < 1e-8) right.set(1, 0, 0)
else right.normalize()
const up = new THREE.Vector3().crossVectors(backward, right).normalize()
return { up, right, backward }
}
export function pointToRollAngle(
point: THREE.Vector3Like,
target: THREE.Vector3Like,
cameraPos: THREE.Vector3Like
): number {
const { up, right } = rollBasis(target, cameraPos)
const rel = new THREE.Vector3(
point.x - target.x,
point.y - target.y,
point.z - target.z
)
return Math.atan2(rel.dot(right), rel.dot(up)) * RAD2DEG
}

View File

@@ -0,0 +1 @@
export type DraggingChangeListener = (dragging: boolean) => void

View File

@@ -0,0 +1,202 @@
import * as THREE from 'three'
import { describe, expect, it } from 'vitest'
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
const RAD2DEG = 180 / Math.PI
function stateWith(overrides: Partial<CameraInfoState>): CameraInfoState {
return { ...structuredClone(DEFAULT_CAMERA_INFO_STATE), ...overrides }
}
describe('rotateSubjectByDrag - orbit', () => {
it('adds the drag deltas to yaw and pitch in degrees', () => {
const state = stateWith({ mode: 'orbit' })
const result = rotateSubjectByDrag(state, 0.1, 0.05)
expect(result).not.toBeNull()
expect(result!.nextState.orbit.yaw).toBeCloseTo(
state.orbit.yaw + 0.1 * RAD2DEG
)
expect(result!.nextState.orbit.pitch).toBeCloseTo(
state.orbit.pitch + 0.05 * RAD2DEG
)
expect(result!.updates.map((u) => u.fieldName)).toEqual([
'mode.yaw',
'mode.pitch'
])
})
it('clamps pitch to the pole limit', () => {
const state = stateWith({
mode: 'orbit',
orbit: { yaw: 0, pitch: 80, distance: 4 }
})
const result = rotateSubjectByDrag(state, 0, 1)
expect(result!.nextState.orbit.pitch).toBe(89)
})
})
describe('rotateSubjectByDrag - look_at', () => {
it('rotates the target around a fixed camera position, preserving distance', () => {
const state = stateWith({
mode: 'look_at',
lookAt: { position: { x: 0, y: 0, z: 5 } },
target: { x: 0, y: 0, z: 0 }
})
const result = rotateSubjectByDrag(state, 0.2, 0)
expect(result).not.toBeNull()
const pos = new THREE.Vector3(0, 0, 5)
const before = new THREE.Vector3(0, 0, 0).distanceTo(pos)
const nextTarget = result!.nextState.target
const after = new THREE.Vector3(
nextTarget.x,
nextTarget.y,
nextTarget.z
).distanceTo(pos)
expect(after).toBeCloseTo(before)
expect(nextTarget.x).not.toBeCloseTo(0)
expect(result!.updates.map((u) => u.fieldName)).toEqual([
'target_x',
'target_y',
'target_z'
])
})
it('returns null when the camera sits on the target', () => {
const state = stateWith({
mode: 'look_at',
lookAt: { position: { x: 1, y: 1, z: 1 } },
target: { x: 1, y: 1, z: 1 }
})
expect(rotateSubjectByDrag(state, 0.1, 0.1)).toBeNull()
})
})
describe('rotateSubjectByDrag - quaternion', () => {
it('rotates the orientation and keeps a unit quaternion', () => {
const state = stateWith({
mode: 'quaternion',
quaternion: {
position: { x: 0, y: 0, z: 5 },
quat: { x: 0, y: 0, z: 0, w: 1 }
}
})
const result = rotateSubjectByDrag(state, 0.2, 0.1)
expect(result).not.toBeNull()
const q = result!.nextState.quaternion.quat
const length = Math.hypot(q.x, q.y, q.z, q.w)
expect(length).toBeCloseTo(1)
expect(q).not.toEqual({ x: 0, y: 0, z: 0, w: 1 })
expect(result!.nextState.quaternion.position).toEqual({ x: 0, y: 0, z: 5 })
expect(result!.updates.map((u) => u.fieldName)).toEqual([
'mode.quat_x',
'mode.quat_y',
'mode.quat_z',
'mode.quat_w'
])
})
it('treats a zero-length quaternion as identity', () => {
const state = stateWith({
mode: 'quaternion',
quaternion: {
position: { x: 0, y: 0, z: 0 },
quat: { x: 0, y: 0, z: 0, w: 0 }
}
})
const result = rotateSubjectByDrag(state, 0.1, 0)
const q = result!.nextState.quaternion.quat
expect(Math.hypot(q.x, q.y, q.z, q.w)).toBeCloseTo(1)
})
})
describe('dollySubjectByWheel - perspective', () => {
it('orbit: scrolling up shortens distance, scrolling down lengthens it', () => {
const state = stateWith({ mode: 'orbit', cameraType: 'perspective' })
const closer = rotateResultDistance(dollySubjectByWheel(state, -100))
const farther = rotateResultDistance(dollySubjectByWheel(state, 100))
expect(closer).toBeLessThan(state.orbit.distance)
expect(farther).toBeGreaterThan(state.orbit.distance)
})
it('orbit: clamps distance to the same bounds as the handle drag', () => {
const state = stateWith({
mode: 'orbit',
orbit: { yaw: 0, pitch: 0, distance: 4 }
})
expect(dollySubjectByWheel(state, -100000)!.nextState.orbit.distance).toBe(
0.5
)
expect(dollySubjectByWheel(state, 100000)!.nextState.orbit.distance).toBe(
100
)
})
it('look_at: moves the position toward the target, preserving direction', () => {
const state = stateWith({
mode: 'look_at',
lookAt: { position: { x: 0, y: 0, z: 5 } },
target: { x: 0, y: 0, z: 0 }
})
const result = dollySubjectByWheel(state, -100)
const pos = result!.nextState.lookAt.position
expect(pos.z).toBeGreaterThan(0)
expect(pos.z).toBeLessThan(5)
expect(pos.x).toBeCloseTo(0)
expect(pos.y).toBeCloseTo(0)
})
it('quaternion: moves the position along the camera forward axis', () => {
const state = stateWith({
mode: 'quaternion',
quaternion: {
position: { x: 0, y: 0, z: 5 },
quat: { x: 0, y: 0, z: 0, w: 1 }
}
})
const result = dollySubjectByWheel(state, -100)
const pos = result!.nextState.quaternion.position
expect(pos.z).toBeCloseTo(4)
expect(pos.x).toBeCloseTo(0)
expect(pos.y).toBeCloseTo(0)
})
})
describe('dollySubjectByWheel - orthographic', () => {
it('changes zoom rather than position, regardless of mode', () => {
const state = stateWith({ mode: 'orbit', cameraType: 'orthographic' })
const zoomedIn = dollySubjectByWheel(state, -100)
const zoomedOut = dollySubjectByWheel(state, 100)
expect(zoomedIn!.updates[0].fieldName).toBe('zoom')
expect(zoomedIn!.nextState.zoom).toBeGreaterThan(state.zoom)
expect(zoomedOut!.nextState.zoom).toBeLessThan(state.zoom)
expect(zoomedIn!.nextState.orbit.distance).toBe(state.orbit.distance)
})
it('clamps zoom within bounds', () => {
const state = stateWith({ cameraType: 'orthographic', zoom: 1 })
expect(dollySubjectByWheel(state, -100000)!.nextState.zoom).toBe(100)
expect(dollySubjectByWheel(state, 100000)!.nextState.zoom).toBe(0.05)
})
})
function rotateResultDistance(
result: ReturnType<typeof dollySubjectByWheel>
): number {
return result!.nextState.orbit.distance
}

View File

@@ -0,0 +1,271 @@
import { clamp } from 'es-toolkit'
import * as THREE from 'three'
import { normalizeQuaternion } from './cameraTransform'
import {
MAX_DISTANCE,
MAX_PITCH,
MIN_DISTANCE,
MIN_PITCH
} from './handles/orbitDragMath'
import type { CameraInfoFieldName, CameraInfoState } from './types'
const DEG2RAD = Math.PI / 180
const RAD2DEG = 180 / Math.PI
const ELEVATION_LIMIT = MAX_PITCH * DEG2RAD
const DOLLY_EXP_SENSITIVITY = 0.0015
const FREE_DOLLY_UNIT = 0.01
const MIN_ZOOM = 0.05
const MAX_ZOOM = 100
interface FieldUpdate {
fieldName: CameraInfoFieldName
value: number
}
export interface LookThroughResult {
nextState: CameraInfoState
updates: FieldUpdate[]
}
function directionToSpherical(dir: THREE.Vector3): {
azimuth: number
elevation: number
} {
return {
azimuth: Math.atan2(dir.x, dir.z),
elevation: Math.asin(clamp(dir.y, -1, 1))
}
}
function sphericalToDirection(
azimuth: number,
elevation: number
): THREE.Vector3 {
const ce = Math.cos(elevation)
return new THREE.Vector3(
ce * Math.sin(azimuth),
Math.sin(elevation),
ce * Math.cos(azimuth)
)
}
function rotateOrbit(
state: CameraInfoState,
yawDelta: number,
pitchDelta: number
): LookThroughResult {
const yaw = state.orbit.yaw + yawDelta * RAD2DEG
const pitch = clamp(
state.orbit.pitch + pitchDelta * RAD2DEG,
MIN_PITCH,
MAX_PITCH
)
return {
nextState: { ...state, orbit: { ...state.orbit, yaw, pitch } },
updates: [
{ fieldName: 'mode.yaw', value: yaw },
{ fieldName: 'mode.pitch', value: pitch }
]
}
}
function rotateLookAt(
state: CameraInfoState,
yawDelta: number,
pitchDelta: number
): LookThroughResult | null {
const position = new THREE.Vector3(
state.lookAt.position.x,
state.lookAt.position.y,
state.lookAt.position.z
)
const target = new THREE.Vector3(
state.target.x,
state.target.y,
state.target.z
)
const offset = target.clone().sub(position)
const distance = offset.length()
if (distance < 1e-6) return null
const { azimuth, elevation } = directionToSpherical(
offset.clone().normalize()
)
const nextAzimuth = azimuth + yawDelta
const nextElevation = clamp(
elevation + pitchDelta,
-ELEVATION_LIMIT,
ELEVATION_LIMIT
)
const dir = sphericalToDirection(nextAzimuth, nextElevation)
const nextTarget: THREE.Vector3Like = {
x: position.x + dir.x * distance,
y: position.y + dir.y * distance,
z: position.z + dir.z * distance
}
return {
nextState: { ...state, target: nextTarget },
updates: [
{ fieldName: 'target_x', value: nextTarget.x },
{ fieldName: 'target_y', value: nextTarget.y },
{ fieldName: 'target_z', value: nextTarget.z }
]
}
}
function rotateQuaternion(
state: CameraInfoState,
yawDelta: number,
pitchDelta: number
): LookThroughResult {
const q = normalizeQuaternion(
new THREE.Quaternion(
state.quaternion.quat.x,
state.quaternion.quat.y,
state.quaternion.quat.z,
state.quaternion.quat.w
)
)
const yawQ = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
yawDelta
)
const pitchQ = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
pitchDelta
)
q.premultiply(yawQ).multiply(pitchQ).normalize()
const quat = { x: q.x, y: q.y, z: q.z, w: q.w }
return {
nextState: { ...state, quaternion: { ...state.quaternion, quat } },
updates: [
{ fieldName: 'mode.quat_x', value: quat.x },
{ fieldName: 'mode.quat_y', value: quat.y },
{ fieldName: 'mode.quat_z', value: quat.z },
{ fieldName: 'mode.quat_w', value: quat.w }
]
}
}
export function rotateSubjectByDrag(
state: CameraInfoState,
yawDelta: number,
pitchDelta: number
): LookThroughResult | null {
switch (state.mode) {
case 'orbit':
return rotateOrbit(state, yawDelta, pitchDelta)
case 'look_at':
return rotateLookAt(state, yawDelta, pitchDelta)
case 'quaternion':
return rotateQuaternion(state, yawDelta, pitchDelta)
}
}
function dollyOrbit(state: CameraInfoState, deltaY: number): LookThroughResult {
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
const distance = clamp(
state.orbit.distance * factor,
MIN_DISTANCE,
MAX_DISTANCE
)
return {
nextState: { ...state, orbit: { ...state.orbit, distance } },
updates: [{ fieldName: 'mode.distance', value: distance }]
}
}
function dollyLookAt(
state: CameraInfoState,
deltaY: number
): LookThroughResult | null {
const position = new THREE.Vector3(
state.lookAt.position.x,
state.lookAt.position.y,
state.lookAt.position.z
)
const target = new THREE.Vector3(
state.target.x,
state.target.y,
state.target.z
)
const offset = position.clone().sub(target)
const distance = offset.length()
if (distance < 1e-6) return null
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
const nextDistance = Math.max(MIN_DISTANCE, distance * factor)
const next = target
.clone()
.add(offset.multiplyScalar(nextDistance / distance))
const nextPosition: THREE.Vector3Like = { x: next.x, y: next.y, z: next.z }
return {
nextState: { ...state, lookAt: { position: nextPosition } },
updates: [
{ fieldName: 'mode.position_x', value: nextPosition.x },
{ fieldName: 'mode.position_y', value: nextPosition.y },
{ fieldName: 'mode.position_z', value: nextPosition.z }
]
}
}
function dollyQuaternion(
state: CameraInfoState,
deltaY: number
): LookThroughResult {
const q = normalizeQuaternion(
new THREE.Quaternion(
state.quaternion.quat.x,
state.quaternion.quat.y,
state.quaternion.quat.z,
state.quaternion.quat.w
)
)
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(q)
const step = -deltaY * FREE_DOLLY_UNIT
const p = state.quaternion.position
const nextPosition: THREE.Vector3Like = {
x: p.x + forward.x * step,
y: p.y + forward.y * step,
z: p.z + forward.z * step
}
return {
nextState: {
...state,
quaternion: { ...state.quaternion, position: nextPosition }
},
updates: [
{ fieldName: 'mode.position_x', value: nextPosition.x },
{ fieldName: 'mode.position_y', value: nextPosition.y },
{ fieldName: 'mode.position_z', value: nextPosition.z }
]
}
}
function dollyZoom(state: CameraInfoState, deltaY: number): LookThroughResult {
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
const zoom = clamp(state.zoom / factor, MIN_ZOOM, MAX_ZOOM)
return {
nextState: { ...state, zoom },
updates: [{ fieldName: 'zoom', value: zoom }]
}
}
export function dollySubjectByWheel(
state: CameraInfoState,
deltaY: number
): LookThroughResult | null {
if (state.cameraType === 'orthographic') return dollyZoom(state, deltaY)
switch (state.mode) {
case 'orbit':
return dollyOrbit(state, deltaY)
case 'look_at':
return dollyLookAt(state, deltaY)
case 'quaternion':
return dollyQuaternion(state, deltaY)
}
}

View File

@@ -0,0 +1,65 @@
import type { QuaternionLike, Vector3Like } from 'three'
export type CameraInfoMode = 'orbit' | 'look_at' | 'quaternion'
export type CameraInfoCameraType = 'perspective' | 'orthographic'
export type CameraInfoFieldName =
| 'mode.yaw'
| 'mode.pitch'
| 'mode.distance'
| 'target_x'
| 'target_y'
| 'target_z'
| 'mode.position_x'
| 'mode.position_y'
| 'mode.position_z'
| 'mode.quat_x'
| 'mode.quat_y'
| 'mode.quat_z'
| 'mode.quat_w'
| 'roll'
| 'fov'
| 'zoom'
interface OrbitInputs {
yaw: number
pitch: number
distance: number
}
interface LookAtInputs {
position: Vector3Like
}
interface QuaternionInputs {
position: Vector3Like
quat: QuaternionLike
}
export interface CameraInfoState {
mode: CameraInfoMode
target: Vector3Like
roll: number
fov: number
zoom: number
cameraType: CameraInfoCameraType
orbit: OrbitInputs
lookAt: LookAtInputs
quaternion: QuaternionInputs
}
export const DEFAULT_CAMERA_INFO_STATE: CameraInfoState = {
mode: 'orbit',
target: { x: 0, y: 0, z: 0 },
roll: 0,
fov: 35,
zoom: 1,
cameraType: 'perspective',
orbit: { yaw: 35, pitch: 30, distance: 4 },
lookAt: { position: { x: 4, y: 4, z: 4 } },
quaternion: {
position: { x: 4, y: 4, z: 4 },
quat: { x: 0, y: 0, z: 0, w: 1 }
}
}

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CAMERA_INFO_STATE } from './types'
import {
readStateFromWidgets,
writeWidgetValue,
type NodeWithWidgets
} from './widgetBridge'
function nodeWithWidgets(
values: Record<string, unknown>
): NodeWithWidgets & { widgets: { name: string; value: unknown }[] } {
return {
widgets: Object.entries(values).map(([name, value]) => ({ name, value }))
}
}
describe('readStateFromWidgets', () => {
it('returns defaults when the node has no widgets at all', () => {
expect(readStateFromWidgets({})).toEqual(DEFAULT_CAMERA_INFO_STATE)
})
it('reads each widget by name (orbit-mode example)', () => {
const node = nodeWithWidgets({
mode: 'orbit',
target_x: 1,
target_y: 2,
target_z: 3,
roll: 10,
fov: 50,
zoom: 0.8,
camera_type: 'perspective',
'mode.yaw': 25,
'mode.pitch': 40,
'mode.distance': 7
})
const state = readStateFromWidgets(node)
expect(state.mode).toBe('orbit')
expect(state.target).toEqual({ x: 1, y: 2, z: 3 })
expect(state.roll).toBe(10)
expect(state.fov).toBe(50)
expect(state.zoom).toBe(0.8)
expect(state.orbit).toEqual({ yaw: 25, pitch: 40, distance: 7 })
})
it('falls back when a widget value has the wrong type', () => {
const node = nodeWithWidgets({
fov: 'not-a-number',
mode: 'invalid-mode',
camera_type: 42
})
const state = readStateFromWidgets(node)
expect(state.fov).toBe(DEFAULT_CAMERA_INFO_STATE.fov)
expect(state.mode).toBe(DEFAULT_CAMERA_INFO_STATE.mode)
expect(state.cameraType).toBe(DEFAULT_CAMERA_INFO_STATE.cameraType)
})
it('rejects non-finite numbers (NaN / Infinity)', () => {
const node = nodeWithWidgets({
'mode.yaw': Number.NaN,
'mode.distance': Number.POSITIVE_INFINITY
})
const state = readStateFromWidgets(node)
expect(state.orbit.yaw).toBe(DEFAULT_CAMERA_INFO_STATE.orbit.yaw)
expect(state.orbit.distance).toBe(DEFAULT_CAMERA_INFO_STATE.orbit.distance)
})
it('reads quaternion mode widgets', () => {
const node = nodeWithWidgets({
mode: 'quaternion',
'mode.position_x': 1,
'mode.position_y': 2,
'mode.position_z': 3,
'mode.quat_x': 0,
'mode.quat_y': 0,
'mode.quat_z': 0,
'mode.quat_w': 1
})
const state = readStateFromWidgets(node)
expect(state.mode).toBe('quaternion')
expect(state.quaternion.position).toEqual({ x: 1, y: 2, z: 3 })
expect(state.quaternion.quat).toEqual({ x: 0, y: 0, z: 0, w: 1 })
})
})
describe('writeWidgetValue', () => {
it('updates the named widget when the value differs', () => {
const node = nodeWithWidgets({ 'mode.yaw': 0 })
writeWidgetValue(node, 'mode.yaw', 45)
expect(node.widgets.find((w) => w.name === 'mode.yaw')!.value).toBe(45)
})
it('is a no-op when the value already matches', () => {
const node = nodeWithWidgets({ fov: 35 })
const before = node.widgets[0].value
writeWidgetValue(node, 'fov', 35)
expect(node.widgets[0].value).toBe(before)
})
it('is a no-op when the widget does not exist', () => {
const node = nodeWithWidgets({ fov: 35 })
expect(() => writeWidgetValue(node, 'does_not_exist', 1)).not.toThrow()
expect(node.widgets).toHaveLength(1)
})
})

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