Compare commits

..

13 Commits

Author SHA1 Message Date
Terry Jia
2162a93d29 feat(camera-info): 3D viewport widget for CreateCameraInfo node 2026-07-18 08:55:17 -04:00
Angelo Lesniak
e6c04359b5 fix: load mask editor layers from the saved image's subfolder (#13744)
## Summary

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

## Changes

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

## Review Focus

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

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

## Changes

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

## Review Focus

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

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

## Screenshots (if applicable)
2026-07-17 08:47:18 +00:00
AustinMroz
3be998aaf4 Add indicator for free tier quota system (#13657)
Adds frontend support for a quota based free tier.
- Adds a indicator component beneath the run button (docked/undocked/app
mode)
- Tracks available usages as workflows are queued. This is enforced by
backend, but updated quota information is not sent after initial load.
- Tracks partner node presence in graph (Workflows with partner nodes
are not allowed under the quota system)

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

---------

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

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

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

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

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

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


## Gated on the plan, not the workspace type

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

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

Two deliberate non-choices:

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

## Fixes payment_failed, which was dead code

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

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

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

## Renders

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

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

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

## Feature flag

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

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

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

## Backend status

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

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

## How has this been tested?

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

## Known gaps

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


## screenshots

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

## Summary

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

N/A

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

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

---

## Summary

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

## Design

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

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

## Hidden-page mechanics

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

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

## Next step (not in this PR)

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

## Verification

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

## Screenshots

## Screenshots

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

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

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

---------

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

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

## Changes

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

## Review Focus

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

Validation: `pnpm typecheck`, focused Vitest, oxlint, ESLint, and oxfmt.
2026-07-16 22:08:01 +00:00
steven-comfy
f524683f3c feat: add hidden /individual-submission page for sales-form redirects (#13697)
*PR Created by the Glary-Bot Agent*

---

## Summary

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

## Design

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

## Hidden-page mechanics

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

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

## Next step (not in this PR)

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

## Verification

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

## Screenshots

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

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

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: imick-io <michael@concreo.io>
Co-authored-by: Michael B <michael@imick.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:49:30 +00:00
Benjamin Lu
d1d55585f9 [GTM-278] Report Firebase auth state to Desktop telemetry (#13687)
## Summary

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

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

## Changes

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

## Ownership and privacy

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

## Review and rollout order

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

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

## Testing

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

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

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

## Changes

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

## Review Focus

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

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

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

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

## Summary

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

## Changes

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

## Incident evidence

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

## Review Focus

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

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

## Validation

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

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

## Rollout / backfill

- Deploy or backport this change to the active cloud release line.
- Verify new-user Customer.io profiles receive a non-null email
identifier before the auth event is processed, then monitor Campaign 1
starts against the pre-July baseline.
- Backfill or re-trigger already affected new users separately; this
frontend change repairs future auth deliveries and does not replay
historical events.
- No live Customer.io campaign changes are included.
2026-07-16 00:44:39 +00:00
140 changed files with 9469 additions and 1047 deletions

9
.fallowrc.json Normal file
View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,9 +1,20 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
// The setup island hydrates on visibility; clicks before hydration are
// no-ops, so retry until the tab actually activates.
async function selectClientTab(setup: Locator, name: string) {
const tab = setup.getByRole('tab', { name })
await expect(async () => {
await tab.click()
await expect(tab).toHaveAttribute('data-state', 'active', { timeout: 500 })
}).toPass()
}
test.describe('MCP page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mcp')
@@ -19,46 +30,72 @@ test.describe('MCP page @smoke', () => {
}
})
test('setup section shows both install options', async ({ page }) => {
test('Claude Desktop is the default tab and shows only the connector card', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await expect(
setup.getByRole('tab', { name: 'Claude Desktop' })
).toHaveAttribute('data-state', 'active')
await expect(
setup.getByRole('heading', { name: 'Add Custom Connector' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
await expect(
setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
).toBeVisible()
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
})
test('client tabs swap install instructions', async ({ page }) => {
test('client tabs swap install instructions and agent-card visibility', async ({
page
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
const agentHeading = setup.getByRole('heading', {
name: 'Ask your agent to install Comfy MCP'
})
// 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('Add custom connector')
// First interaction retries until the island hydrates; later switches
// assert synchronously so steady-state click regressions fail.
await selectClientTab(setup, 'Claude Code Terminal')
await expect(activePanel).toContainText(
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
)
await expect(
setup.getByRole('heading', { name: 'Install manually' })
).toBeVisible()
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
await expect(activePanel).toContainText('Add custom connector')
await setup.getByRole('tab', { name: 'Codex' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
)
await expect(agentHeading).toHaveCount(0)
await expect(setup.locator('video')).toBeVisible()
await setup.getByRole('tab', { name: 'Cursor' }).click()
await expect(activePanel).toContainText('X-API-Key')
await expect(
activePanel.getByRole('link', { name: 'platform.comfy.org' })
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Codex' }).click()
await setup.getByRole('tab', { name: 'OpenClaw' }).click()
await expect(activePanel).toContainText(
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
'openclaw skills install @comfy-org/comfy'
)
await expect(agentHeading).toBeVisible()
await setup.getByRole('tab', { name: 'Others' }).click()
await expect(activePanel).toContainText('remote MCP server')
await expect(agentHeading).toBeVisible()
})
test('skills plugin link lives in the agent option card', async ({
@@ -66,6 +103,7 @@ test.describe('MCP page @smoke', () => {
}) => {
const setup = page.locator('#setup')
await setup.scrollIntoViewIfNeeded()
await selectClientTab(setup, 'Claude Code Terminal')
await expect(
setup.getByRole('link', { name: 'View on GitHub' })
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
@@ -105,7 +143,10 @@ test.describe('MCP page zh-CN @smoke', () => {
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 selectClientTab(setup, 'Claude Code Terminal')
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -1919,42 +1919,48 @@ const translations = {
'zh-CN': '配置 Comfy MCP'
},
'mcp.setup.subtitle': {
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.',
en: 'Two ways to connect: add the server yourself, or ask your agent to install it. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
'两种接入方式:自行添加服务器,或让你的智能体自动安装。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'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.option1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'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.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
'mcp.setup.option2.title': {
'mcp.setup.manual.title': {
en: 'Install manually',
'zh-CN': '手动安装'
},
'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.',
'mcp.setup.manual.description': {
en: 'Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
'zh-CN':
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
'将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
},
'mcp.setup.option2.tabsLabel': {
'mcp.setup.manual.tabsLabel': {
en: 'Pick your client',
'zh-CN': '选择你的客户端'
},
'mcp.setup.agent.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.agent.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.agent.description': {
en: 'Prefer to let your agent do it? Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'想让智能体代劳?将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'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.walkthroughAlt': {
en: '{client} setup walkthrough',
'zh-CN': '{client} 设置演示'
},
'mcp.setup.clients.claudeDesktop.manualTitle': {
en: 'Add Custom Connector',
'zh-CN': '添加自定义连接器'
},
'mcp.setup.clients.claudeDesktop.step': {
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
'zh-CN':
@@ -1973,9 +1979,13 @@ const translations = {
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.openclaw.step': {
en: 'Run these in your terminal, then openclaw mcp login comfy to sign in.',
'zh-CN': '在终端运行以下命令,然后执行 openclaw mcp login comfy 登录。'
},
'mcp.setup.clients.other.name': {
en: 'Other clients',
'zh-CN': '其他客户端'
en: 'Others',
'zh-CN': '其他'
},
'mcp.setup.clients.other.step': {
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
@@ -2190,9 +2200,9 @@ const translations = {
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.3.a': {
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.',
en: 'Not for Claude Code, Claude Desktop, Codex, or OpenClaw. You need a Comfy API key for Cursor and Hermes for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
'zh-CN':
'Claude Code、Claude DesktopCodex 不需要。CursorHermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
'Claude Code、Claude DesktopCodex 和 OpenClaw 不需要。CursorHermes 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud你的智能体就会为你完成安装。'
},
'mcp.faq.4.q': {
en: 'Does it cost anything?',

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
import { getRoutes } from '../../config/routes'
const routes = getRoutes('zh-CN')
---
<BaseLayout
title="感谢您的联系 - Comfy"
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
noindex
>
<HeroSection title="感谢您的联系。" class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>、
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
>,以及我们全新的
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> 套餐,可让多位用户共享账单。
</p>
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
<p class="text-primary-warm-gray mt-8 text-sm">
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
需要其他方面的帮助?邮箱:<a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

88
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,160 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
load3d: {
showGizmos: 'Show gizmos',
hideGizmos: 'Hide gizmos',
lookThrough: 'Camera view',
exitLookThrough: 'Exit camera view',
transformGizmo: {
none: 'None',
target: 'Target',
cameraTranslate: 'Cam pos',
cameraRotate: 'Cam rot'
}
}
}
}
})
type ApiMocks = Record<string, ReturnType<typeof vi.fn>>
const holder = vi.hoisted(() => ({
modeRef: null as { value: string } | null,
api: null as ApiMocks | null
}))
vi.mock('@/composables/useCameraInfo', async () => {
const { ref } = await import('vue')
const modeRef = ref('orbit')
const api = {
initialize: vi.fn(),
cleanup: vi.fn(),
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
setGizmosVisible: vi.fn(),
setTransformGizmoMode: vi.fn(),
setLookThrough: vi.fn()
}
holder.modeRef = modeRef
holder.api = api
return { useCameraInfo: () => ({ ...api, mode: modeRef }) }
})
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
const { ref } = await import('vue')
return {
...actual,
useElementSize: () => ({ width: ref(600), height: ref(400) })
}
})
import CameraInfo from './CameraInfo.vue'
function makeWidget(): SimplifiedWidget {
return {
name: 'camera_info_state',
type: 'cameraInfo',
value: [],
options: {}
} as unknown as SimplifiedWidget
}
function renderComponent() {
return render(CameraInfo, {
props: { widget: makeWidget() },
global: {
plugins: [i18n],
directives: { tooltip: {} }
}
})
}
function setMode(value: string) {
holder.modeRef!.value = value
}
function api(): ApiMocks {
return holder.api!
}
describe('CameraInfo toolbar', () => {
beforeEach(() => {
setMode('orbit')
Object.values(api()).forEach((fn) => fn.mockClear())
})
it('initializes the viewport on mount and cleans up on unmount', () => {
const { unmount } = renderComponent()
expect(api().initialize).toHaveBeenCalledOnce()
unmount()
expect(api().cleanup).toHaveBeenCalledOnce()
})
it('toggles gizmo visibility when the gizmos button is clicked', async () => {
renderComponent()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Hide gizmos' }))
expect(api().setGizmosVisible).toHaveBeenCalledWith(false)
})
it('enters camera view when the camera-view button is clicked', async () => {
renderComponent()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Camera view' }))
expect(api().setLookThrough).toHaveBeenCalledWith(true)
})
})
describe('CameraInfo transform gizmo reconciliation', () => {
beforeEach(() => {
setMode('orbit')
Object.values(api()).forEach((fn) => fn.mockClear())
})
it('resets the selected gizmo to none when the new mode disables it', async () => {
setMode('quaternion')
renderComponent()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Cam rot' }))
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith(
'camera-rotate'
)
setMode('orbit')
await nextTick()
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith('none')
})
it('keeps the selected gizmo when the new mode still supports it', async () => {
setMode('orbit')
renderComponent()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Target' }))
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith('target')
setMode('look_at')
await nextTick()
expect(api().setTransformGizmoMode).not.toHaveBeenCalledWith('none')
})
})

View File

@@ -0,0 +1,225 @@
<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))
watch(mode, () => {
const selected = transformGizmoOptions.value.find(
({ value }) => value === transformGizmoMode.value
)
if (!selected?.enabled) transformGizmoMode.value = 'none'
})
onMounted(() => {
if (container.value) initialize(container.value)
})
onUnmounted(() => {
cleanup()
})
</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]',
'size-5 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

@@ -1,141 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import ColorPickerPanel from '@/components/ui/color-picker/ColorPickerPanel.vue'
import type { HSVA } from '@/utils/colorUtil'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
color: {
hex: 'Hex',
rgba: 'RGBA',
hue: 'Hue',
alpha: 'Alpha',
red: 'Red',
green: 'Green',
blue: 'Blue',
saturationBrightness: 'Saturation and brightness'
}
}
}
})
function renderPanel(initial: HSVA, mode: 'hex' | 'rgba' = 'hex') {
const hsva = ref<HSVA>(initial)
const displayMode = ref<'hex' | 'rgba'>(mode)
const Host = defineComponent(
() => () =>
h(ColorPickerPanel, {
hsva: hsva.value,
'onUpdate:hsva': (value: HSVA) => {
hsva.value = value
},
displayMode: displayMode.value,
'onUpdate:displayMode': (value: 'hex' | 'rgba') => {
displayMode.value = value
},
alpha: true
})
)
render(Host, { global: { plugins: [i18n] } })
return { hsva, user: userEvent.setup() }
}
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
describe('ColorPickerPanel hex input', () => {
it('updates the hsva model when a valid 6-digit hex is typed', async () => {
const { hsva, user } = renderPanel({ ...black })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, 'ff0000')
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
})
it('accepts 3-digit shorthand hex', async () => {
const { hsva, user } = renderPanel({ ...black })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, '0f0')
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
})
it('preserves the existing alpha when editing hex', async () => {
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 40 })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, 'ffffff')
expect(hsva.value.a).toBe(40)
})
it('ignores invalid input and leaves the model unchanged', async () => {
const { hsva, user } = renderPanel({ ...black })
const input = screen.getByRole('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, 'nothex')
expect(hsva.value).toEqual(black)
})
it('reformats the draft to canonical hex on blur', async () => {
const { user } = renderPanel({ ...black })
const input = screen.getByRole<HTMLInputElement>('textbox', { name: 'Hex' })
await user.clear(input)
await user.type(input, '0f0')
await user.tab()
expect(input.value).toBe('#00ff00')
})
})
describe('ColorPickerPanel rgba inputs', () => {
it('updates the hsva model when an RGB channel is edited', async () => {
const { hsva, user } = renderPanel({ ...black }, 'rgba')
const red = screen.getByRole('spinbutton', { name: 'Red' })
await user.clear(red)
await user.type(red, '255')
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
})
it('clamps an out-of-range channel to 255 on blur', async () => {
const { user } = renderPanel({ ...black }, 'rgba')
const red = screen.getByRole<HTMLInputElement>('spinbutton', {
name: 'Red'
})
await user.clear(red)
await user.type(red, '300')
await user.tab()
expect(red.value).toBe('255')
})
it('edits alpha as a percentage', async () => {
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 100 })
const alpha = screen.getByRole('spinbutton', { name: 'Alpha' })
await user.clear(alpha)
await user.type(alpha, '50')
expect(hsva.value.a).toBe(50)
})
})

View File

@@ -1,3 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const { t } = useI18n()
</script>
<template>
<div
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
@@ -36,79 +66,19 @@
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<input
v-model="hex.draft"
type="text"
spellcheck="false"
:aria-label="t('color.hex')"
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none"
@focus="hex.beginEdit"
@input="hex.commit"
@keydown.enter="hex.commit"
@blur="hex.reset"
/>
<span class="min-w-0 flex-1 truncate text-center">{{
hexString
}}</span>
</template>
<template v-else>
<input
v-for="channel in rgbChannels"
:key="channel.key"
v-model.number="rgb.draft[channel.key]"
type="number"
:min="0"
:max="255"
:aria-label="t(channel.label)"
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none [&::-webkit-inner-spin-button]:appearance-none"
@focus="rgb.beginEdit"
@input="rgb.commit"
@keydown.enter="rgb.commit"
@blur="rgb.reset"
/>
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<div
v-if="alpha"
class="flex shrink-0 items-center border-l border-border-subtle pl-1"
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
<input
v-model.number="alphaField.draft"
type="number"
:min="0"
:max="100"
:aria-label="t('color.alpha')"
class="w-6 min-w-0 appearance-none border-none bg-transparent p-0 text-right outline-none [&::-webkit-inner-spin-button]:appearance-none"
@focus="alphaField.beginEdit"
@input="alphaField.commit"
@keydown.enter="alphaField.commit"
@blur="alphaField.reset"
/>
<span>%</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
import { rgbChannels, useColorPicker } from './useColorPicker'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const { t } = useI18n()
const { hex, rgb, alpha: alphaField } = useColorPicker(hsva)
</script>

View File

@@ -1,78 +0,0 @@
import { describe, expect, it } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import { useColorPicker } from './useColorPicker'
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
function setup(initial: HSVA) {
const hsva = ref<HSVA>(initial)
const scope = effectScope()
const api = scope.run(() => useColorPicker(hsva))!
return { hsva, api, stop: () => scope.stop() }
}
describe('useColorPicker', () => {
it('commits a valid hex and ignores malformed input', () => {
const { hsva, api } = setup({ ...black })
api.hex.draft = '#00ff00'
api.hex.commit()
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
const unchanged = hsva.value
api.hex.draft = 'zzz'
api.hex.commit()
expect(hsva.value).toBe(unchanged)
})
it('clamps rgb channels before converting to hsv', () => {
const { hsva, api } = setup({ ...black })
api.rgb.draft.r = 300
api.rgb.commit()
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
})
it('clamps alpha to the 0..100 range', () => {
const { hsva, api } = setup({ h: 0, s: 0, v: 0, a: 100 })
api.alpha.draft = 150
api.alpha.commit()
expect(hsva.value.a).toBe(100)
})
it('syncs the draft from the model when not editing', async () => {
const { hsva, api } = setup({ ...black })
hsva.value = { ...hsva.value, h: 0, s: 100, v: 100 }
await nextTick()
expect(api.hex.draft).toBe('#ff0000')
})
it('freezes the draft against external model changes while editing', async () => {
const { hsva, api } = setup({ ...black })
api.hex.beginEdit()
api.hex.draft = '#123456'
hsva.value = { h: 0, s: 100, v: 100, a: 100 }
await nextTick()
expect(api.hex.draft).toBe('#123456')
})
it('resyncs the draft to the model on reset', () => {
const { api } = setup({ ...black })
api.hex.beginEdit()
api.hex.draft = '#abcdef'
api.hex.reset()
expect(api.hex.draft).toBe('#000000')
})
})

View File

@@ -1,79 +0,0 @@
import { clamp } from 'es-toolkit'
import type { Ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import {
hexToHsva,
hsbToRgb,
normalizeHex,
rgbToHex,
rgbToHsv
} from '@/utils/colorUtil'
export const rgbChannels = [
{ key: 'r', label: 'color.red' },
{ key: 'g', label: 'color.green' },
{ key: 'b', label: 'color.blue' }
] as const
function useDraftField<T>(source: () => T, apply: (draft: T) => void) {
const draft = ref(source()) as Ref<T>
const isEditing = ref(false)
watch(source, (value) => {
if (!isEditing.value) draft.value = value
})
return reactive({
draft,
beginEdit: () => {
isEditing.value = true
},
commit: () => apply(draft.value),
reset: () => {
isEditing.value = false
draft.value = source()
}
})
}
export function useColorPicker(hsva: Ref<HSVA>) {
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const hex = useDraftField(
() => hexString.value,
(draft) => {
const normalized = normalizeHex(draft)
if (!normalized) return
const next = hexToHsva(normalized)
hsva.value = { ...hsva.value, h: next.h, s: next.s, v: next.v }
}
)
const rgbField = useDraftField(
() => ({ ...rgb.value }),
({ r, g, b }) => {
if (![r, g, b].every(Number.isFinite)) return
const hsv = rgbToHsv({
r: clamp(Math.round(r), 0, 255),
g: clamp(Math.round(g), 0, 255),
b: clamp(Math.round(b), 0, 255)
})
hsva.value = { ...hsva.value, h: hsv.h, s: hsv.s, v: hsv.v }
}
)
const alpha = useDraftField(
() => hsva.value.a,
(draft) => {
if (!Number.isFinite(draft)) return
hsva.value = { ...hsva.value, a: clamp(Math.round(draft), 0, 100) }
}
)
return { hex, rgb: rgbField, alpha }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,241 @@
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)
})
it('restores the node mouse handlers on cleanup', () => {
const node = makeNode({ mode: 'orbit' })
const originalEnter = vi.fn()
const originalLeave = vi.fn()
node.onMouseEnter = originalEnter
node.onMouseLeave = originalLeave
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
expect(node.onMouseEnter).not.toBe(originalEnter)
camera.cleanup()
expect(node.onMouseEnter).toBe(originalEnter)
expect(node.onMouseLeave).toBe(originalLeave)
})
it('re-wires widgets on a second initialize after cleanup', () => {
const node = makeNode({ mode: 'orbit', target_x: 0 })
const camera = useCameraInfo(nodeRef(node))
camera.initialize(document.createElement('div'))
camera.cleanup()
camera.initialize(document.createElement('div'))
const targetX = widget(node, 'target_x')
targetX.value = 5
targetX.callback!(5)
expect(instances).toHaveLength(2)
expect(instances[1].applyState).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,170 @@
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 { t } from '@/i18n'
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
let wiredNode: LGraphNode | null = null
let originalOnMouseEnter: LGraphNode['onMouseEnter']
let originalOnMouseLeave: LGraphNode['onMouseLeave']
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
if (viewport) cleanup()
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(
t('toastMessages.failedToInitializeCameraInfoViewer')
)
}
}
const cleanup = (): void => {
unwireWidgets()
unwireNodeMouseStatus()
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 {
wiredNode = target
originalOnMouseEnter = target.onMouseEnter
originalOnMouseLeave = target.onMouseLeave
target.onMouseEnter = useChainCallback(target.onMouseEnter, () => {
viewport?.viewport.updateStatusMouseOnNode(true)
viewport?.viewport.refreshViewport()
})
target.onMouseLeave = useChainCallback(target.onMouseLeave, () => {
viewport?.viewport.updateStatusMouseOnNode(false)
})
}
function unwireNodeMouseStatus(): void {
if (!wiredNode) return
wiredNode.onMouseEnter = originalOnMouseEnter
wiredNode.onMouseLeave = originalOnMouseLeave
wiredNode = null
}
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
wrappedSet.delete(widget)
}
wrappedWidgets.length = 0
}
return {
initialize,
cleanup,
handleMouseEnter,
handleMouseLeave,
setGizmosVisible,
setTransformGizmoMode,
setLookThrough,
mode
}
}

View File

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

View File

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

View File

@@ -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,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,145 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { CameraInfoOverlay } from './CameraInfoOverlay'
import { DEFAULT_CAMERA_INFO_STATE } from './types'
import 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 points the helper at it', () => {
ctx.overlay.applyState({
...DEFAULT_CAMERA_INFO_STATE,
cameraType: 'orthographic'
})
expect(ctx.overlay.getSubjectCamera()).toBeInstanceOf(
THREE.OrthographicCamera
)
const newHelper = ctx.scene.children.find(
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
)
expect(newHelper).toBeDefined()
expect(newHelper?.camera).toBe(ctx.overlay.getSubjectCamera())
})
})
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,210 @@
import * as THREE from 'three'
import type { SceneOverlay } from '@/extensions/core/load3d/interfaces'
import { computeSubjectTransform } from './cameraTransform'
import { DEFAULT_CAMERA_INFO_STATE } from './types'
import type { CameraInfoCameraType, 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,427 @@
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 preRender: (() => void) | null = null
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(),
addPreRenderCallback: vi.fn((cb: () => void) => {
preRender = cb
return vi.fn()
}),
addPostRenderCallback: vi.fn((cb: () => void) => {
postRender = cb
return vi.fn()
}),
setExternalActiveCamera: vi.fn(),
forceRender: vi.fn(),
remove: vi.fn(),
runPreRender: () => preRender?.(),
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)
})
it('fits the subject camera aspect in the pre-render pass while looking through', () => {
viewport.setLookThrough(true)
const cam = viewport.overlay.getSubjectCamera() as THREE.PerspectiveCamera
cam.aspect = 1
cam.updateProjectionMatrix()
stub.runPreRender()
expect(cam.aspect).toBeCloseTo(800 / 600)
})
it('leaves the subject camera aspect untouched outside look-through', () => {
const cam = viewport.overlay.getSubjectCamera() as THREE.PerspectiveCamera
cam.aspect = 1
cam.updateProjectionMatrix()
stub.runPreRender()
expect(cam.aspect).toBe(1)
})
})
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)
})
it('hides the transform handles when gizmos are turned off', () => {
viewport.setTransformGizmoMode('target')
expect(viewport.targetHandle.isVisible()).toBe(true)
viewport.setGizmosVisible(false)
expect(viewport.targetHandle.isVisible()).toBe(false)
})
})
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()
})
})
describe('CameraInfoViewport gizmo drag callbacks', () => {
function controlsOf(handle: unknown): {
dispatchEvent(e: { type: string }): void
} {
return (
handle as { controls: { dispatchEvent(e: { type: string }): void } }
).controls
}
it('writes camera position fields when the camera handle changes', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
controlsOf(viewport.cameraHandle).dispatchEvent({ type: 'objectChange' })
expect(onHandleDrag).toHaveBeenCalledWith(
'mode.position_x',
expect.any(Number)
)
viewport.remove()
})
it('writes quaternion fields when the camera handle rotates', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
viewport.setTransformGizmoMode('camera-rotate')
controlsOf(viewport.cameraHandle).dispatchEvent({ type: 'objectChange' })
expect(onHandleDrag).toHaveBeenCalledWith('mode.quat_w', expect.any(Number))
viewport.remove()
})
it('writes target fields when the target handle changes', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const onHandleDrag = vi.fn()
const viewport = new CameraInfoViewport(
document.createElement('div'),
undefined,
{ onHandleDrag }
)
controlsOf(viewport.targetHandle).dispatchEvent({ type: 'objectChange' })
expect(onHandleDrag).toHaveBeenCalledWith('target_x', expect.any(Number))
viewport.remove()
})
it('toggles orbit controls while a handle drag is active', () => {
const stub = makeViewportStub()
createViewport3dMock.mockReturnValue(stub)
const viewport = new CameraInfoViewport(document.createElement('div'))
controlsOf(viewport.targetHandle).dispatchEvent({
type: 'dragging-changed',
value: true
} as { type: string })
expect(stub.controlsManager.controls.enabled).toBe(false)
controlsOf(viewport.targetHandle).dispatchEvent({
type: 'dragging-changed',
value: false
} as { type: string })
expect(stub.controlsManager.controls.enabled).toBe(true)
viewport.remove()
})
})

View File

@@ -0,0 +1,700 @@
import * as THREE from 'three'
import type { 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 } from './handles/CameraHandle'
import type {
CameraHandleMode,
CameraHandleTransform
} from './handles/CameraHandle'
import { OrbitHandles } from './handles/OrbitHandles'
import 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 } from './types'
import type {
CameraInfoFieldName,
CameraInfoMode,
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 disposePreRender: () => void
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.disposePreRender = this.viewport.addPreRenderCallback(() => {
if (this.lookingThrough) this.fitSubjectAspect()
})
this.disposePostRender = this.viewport.addPostRenderCallback(() => {
if (!this.lookingThrough) this.renderSubjectCameraPreview()
})
}
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.disposePreRender()
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.gizmosOn &&
this.transformGizmoMode === 'target' &&
targetApplies(mode)
this.targetHandle.setVisible(wantTarget)
const wantTranslate =
this.gizmosOn &&
this.transformGizmoMode === 'camera-translate' &&
cameraTranslateApplies(mode)
const wantRotate =
this.gizmosOn &&
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 fitSubjectAspect(): void {
const canvas = this.viewport.domElement
const aspect = canvas.width / canvas.height
if (!Number.isFinite(aspect) || aspect <= 0) return
const cam = this.overlay.getSubjectCamera()
if (cam instanceof THREE.PerspectiveCamera) {
if (Math.abs(cam.aspect - aspect) < 1e-4) return
cam.aspect = aspect
cam.updateProjectionMatrix()
return
}
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
cam.left = left
cam.right = right
cam.updateProjectionMatrix()
}
}
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,162 @@
import * as THREE from 'three'
import { describe, expect, it } from 'vitest'
import { computeSubjectTransform } from './cameraTransform'
import { DEFAULT_CAMERA_INFO_STATE } from './types'
import 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())
const forward = new THREE.Vector3(0, 0, -1)
expect(
forward
.clone()
.applyQuaternion(a.quaternion)
.angleTo(forward.clone().applyQuaternion(b.quaternion))
).toBeCloseTo(0)
const up = new THREE.Vector3(0, 1, 0)
expect(
up
.clone()
.applyQuaternion(a.quaternion)
.angleTo(up.clone().applyQuaternion(b.quaternion))
).toBeCloseTo(Math.PI / 4)
})
})
})

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,127 @@
import * as THREE from 'three'
import type { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CameraHandle } from './CameraHandle'
import type { CameraHandleMode, CameraHandleTransform } from './CameraHandle'
describe('CameraHandle', () => {
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let dom: HTMLElement
let onDragging: (dragging: boolean) => void
let onDraggingMock: ReturnType<typeof vi.fn>
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')
onDraggingMock = vi.fn()
onDragging = onDraggingMock 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)
})
function controls(): TransformControls {
return (handle as unknown as { controls: TransformControls }).controls
}
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('emits the proxy transform and active mode on gizmo objectChange', () => {
const proxy = scene.children.find(
(c) => c.name === 'CameraInfoCameraProxy'
)!
proxy.position.set(1, 2, 3)
controls().dispatchEvent({ type: 'objectChange' })
expect(onChangeMock).toHaveBeenCalledWith(
expect.objectContaining({ position: { x: 1, y: 2, z: 3 } }),
'translate'
)
})
it('forwards dragging-changed to the dragging listener', () => {
controls().dispatchEvent({ type: 'dragging-changed', value: true })
expect(onDraggingMock).toHaveBeenCalledWith(true)
})
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,84 @@
import * as THREE from 'three'
import type { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TargetHandle } from './TargetHandle'
describe('TargetHandle', () => {
let scene: THREE.Scene
let onDragging: ReturnType<typeof vi.fn>
let onChange: ReturnType<typeof vi.fn>
let handle: TargetHandle
beforeEach(() => {
scene = new THREE.Scene()
onDragging = vi.fn()
onChange = vi.fn()
handle = new TargetHandle(
new THREE.PerspectiveCamera(),
document.createElement('div'),
onDragging as unknown as (dragging: boolean) => void,
onChange as unknown as (target: THREE.Vector3Like) => void
)
handle.attach(scene)
})
afterEach(() => {
handle.dispose()
})
function proxy(): THREE.Object3D {
return scene.children.find((c) => c.name === 'CameraInfoTargetProxy')!
}
function controls(): TransformControls {
return (handle as unknown as { controls: TransformControls }).controls
}
it('adds proxy + helper to the scene on attach', () => {
expect(proxy()).toBeDefined()
expect(
scene.children.find((c) => c.name === 'CameraInfoTargetHandle')
).toBeDefined()
})
it('starts hidden and toggles visibility', () => {
expect(handle.isVisible()).toBe(false)
handle.setVisible(true)
expect(handle.isVisible()).toBe(true)
})
it('setTarget moves the proxy without echoing onChange', () => {
handle.setTarget({ x: 1, y: 2, z: 3 })
expect(proxy().position.toArray()).toEqual([1, 2, 3])
expect(onChange).not.toHaveBeenCalled()
})
it('setTarget is a no-op when the position already matches', () => {
handle.setTarget({ x: 0, y: 0, z: 0 })
expect(onChange).not.toHaveBeenCalled()
})
it('emits onChange with the proxy position on gizmo objectChange', () => {
proxy().position.set(4, 5, 6)
controls().dispatchEvent({ type: 'objectChange' })
expect(onChange).toHaveBeenCalledWith({ x: 4, y: 5, z: 6 })
})
it('forwards dragging-changed to onDraggingChange', () => {
controls().dispatchEvent({ type: 'dragging-changed', value: true })
expect(onDragging).toHaveBeenCalledWith(true)
})
it('dispose removes proxy and helper', () => {
handle.dispose()
expect(proxy()).toBeUndefined()
expect(
scene.children.find((c) => c.name === 'CameraInfoTargetHandle')
).toBeUndefined()
})
})

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,104 @@
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', () => {
// The pointer misses both spheres (no direct raycast hit) but sits inside
// the screen-space tolerance of each centre; the nearer centre wins.
const near = makeHandle('yaw', new THREE.Vector3(0.05, 0, 5))
const far = makeHandle('pitch', new THREE.Vector3(-0.05, 0, 5))
const picked = pickHandleAtPointer(
raycaster,
new THREE.Vector2(0.01, -0.04),
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,75 @@
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)
})
it('returns a valid orthonormal basis when the camera sits on the target', () => {
const basis = rollBasis(TARGET, { x: 0, y: 0, z: 0 })
expect(basis.backward.length()).toBeCloseTo(1)
expect(basis.right.length()).toBeCloseTo(1)
expect(basis.up.length()).toBeCloseTo(1)
expect(basis.up.dot(basis.right)).toBeCloseTo(0)
expect(basis.up.dot(basis.backward)).toBeCloseTo(0)
expect(basis.right.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,42 @@
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
)
if (backward.lengthSq() < 1e-8) backward.set(0, 0, 1)
else backward.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,214 @@
import * as THREE from 'three'
import { describe, expect, it } from 'vitest'
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
import { DEFAULT_CAMERA_INFO_STATE } from './types'
import 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('look_at: clamps distance to the shared maximum on scroll-out', () => {
const state = stateWith({
mode: 'look_at',
lookAt: { position: { x: 0, y: 0, z: 5 } },
target: { x: 0, y: 0, z: 0 }
})
const pos = dollySubjectByWheel(state, 100000)!.nextState.lookAt.position
expect(Math.hypot(pos.x, pos.y, pos.z)).toBeCloseTo(100)
})
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 = clamp(distance * factor, MIN_DISTANCE, MAX_DISTANCE)
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,110 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CAMERA_INFO_STATE } from './types'
import { readStateFromWidgets, writeWidgetValue } from './widgetBridge'
import 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)
})
})

View File

@@ -0,0 +1,103 @@
import { DEFAULT_CAMERA_INFO_STATE } from './types'
import type {
CameraInfoCameraType,
CameraInfoMode,
CameraInfoState
} from './types'
interface WidgetLike {
name: string
value: unknown
}
export interface NodeWithWidgets {
widgets?: WidgetLike[]
}
const VALID_MODES: readonly CameraInfoMode[] = [
'orbit',
'look_at',
'quaternion'
]
const VALID_CAMERA_TYPES: readonly CameraInfoCameraType[] = [
'perspective',
'orthographic'
]
function widgetByName(
node: NodeWithWidgets,
name: string
): WidgetLike | undefined {
return node.widgets?.find((w) => w.name === name)
}
function num(node: NodeWithWidgets, name: string, fallback: number): number {
const v = widgetByName(node, name)?.value
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
function pickMode(node: NodeWithWidgets): CameraInfoMode {
const v = widgetByName(node, 'mode')?.value
return typeof v === 'string' && (VALID_MODES as readonly string[]).includes(v)
? (v as CameraInfoMode)
: DEFAULT_CAMERA_INFO_STATE.mode
}
function pickCameraType(node: NodeWithWidgets): CameraInfoCameraType {
const v = widgetByName(node, 'camera_type')?.value
return typeof v === 'string' &&
(VALID_CAMERA_TYPES as readonly string[]).includes(v)
? (v as CameraInfoCameraType)
: DEFAULT_CAMERA_INFO_STATE.cameraType
}
export function readStateFromWidgets(node: NodeWithWidgets): CameraInfoState {
const d = DEFAULT_CAMERA_INFO_STATE
return {
mode: pickMode(node),
target: {
x: num(node, 'target_x', d.target.x),
y: num(node, 'target_y', d.target.y),
z: num(node, 'target_z', d.target.z)
},
roll: num(node, 'roll', d.roll),
fov: num(node, 'fov', d.fov),
zoom: num(node, 'zoom', d.zoom),
cameraType: pickCameraType(node),
orbit: {
yaw: num(node, 'mode.yaw', d.orbit.yaw),
pitch: num(node, 'mode.pitch', d.orbit.pitch),
distance: num(node, 'mode.distance', d.orbit.distance)
},
lookAt: {
position: {
x: num(node, 'mode.position_x', d.lookAt.position.x),
y: num(node, 'mode.position_y', d.lookAt.position.y),
z: num(node, 'mode.position_z', d.lookAt.position.z)
}
},
quaternion: {
position: {
x: num(node, 'mode.position_x', d.quaternion.position.x),
y: num(node, 'mode.position_y', d.quaternion.position.y),
z: num(node, 'mode.position_z', d.quaternion.position.z)
},
quat: {
x: num(node, 'mode.quat_x', d.quaternion.quat.x),
y: num(node, 'mode.quat_y', d.quaternion.quat.y),
z: num(node, 'mode.quat_z', d.quaternion.quat.z),
w: num(node, 'mode.quat_w', d.quaternion.quat.w)
}
}
}
}
export function writeWidgetValue(
node: NodeWithWidgets,
name: string,
value: number | string
): void {
const widget = widgetByName(node, name)
if (!widget || widget.value === value) return
widget.value = value
}

View File

@@ -266,4 +266,61 @@ describe('CameraManager', () => {
])
})
})
describe('custom up', () => {
function rolledState(): CameraState {
const q = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 0, 1),
Math.PI / 2
)
return {
position: new THREE.Vector3(0, 0, 5),
target: new THREE.Vector3(0, 0, 0),
zoom: 1,
cameraType: 'perspective',
quaternion: { x: q.x, y: q.y, z: q.z, w: q.w }
}
}
it('derives the camera up from an incoming quaternion and flags custom up', () => {
manager.setCameraState(rolledState())
expect(manager.activeCamera.up.x).toBeCloseTo(-1)
expect(manager.activeCamera.up.y).toBeCloseTo(0)
expect(events.emitEvent).toHaveBeenCalledWith('cameraUpStateChange', {
hasCustomUp: true,
usingCustomUp: true
})
})
it('toggles between the derived up and world Y', () => {
manager.setCameraState(rolledState())
manager.setUseCustomUp(false)
expect(manager.activeCamera.up.toArray()).toEqual([0, 1, 0])
manager.setUseCustomUp(true)
expect(manager.activeCamera.up.x).toBeCloseTo(-1)
})
it('setUseCustomUp(true) is a no-op when no custom up was captured', () => {
manager.setUseCustomUp(true)
expect(manager.activeCamera.up.toArray()).toEqual([0, 1, 0])
expect(events.emitEvent).not.toHaveBeenCalledWith(
'cameraUpStateChange',
expect.anything()
)
})
it('preserves the up vector when the camera type is toggled', () => {
manager.setCameraState(rolledState())
const up = manager.activeCamera.up.clone()
manager.toggleCamera('orthographic')
expect(manager.activeCamera.up.toArray()).toEqual(up.toArray())
expect(manager.activeCamera.up.x).toBeCloseTo(-1)
})
})
})

View File

@@ -17,6 +17,9 @@ export class CameraManager implements CameraManagerInterface {
private controls: OrbitControls | null = null
private customUp: THREE.Vector3 | null = null
private usingCustomUp = false
DEFAULT_DISTANCE = 10
DEFAULT_LOOK_AT = 0
@@ -91,6 +94,7 @@ export class CameraManager implements CameraManagerInterface {
const position = oldCamera.position.clone()
const rotation = oldCamera.rotation.clone()
const up = oldCamera.up.clone()
const target = this.controls?.target.clone() || new THREE.Vector3()
const oldZoom =
@@ -116,6 +120,7 @@ export class CameraManager implements CameraManagerInterface {
this.activeCamera.position.copy(position)
this.activeCamera.rotation.copy(rotation)
this.activeCamera.up.copy(up)
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = oldZoom
@@ -171,10 +176,21 @@ export class CameraManager implements CameraManagerInterface {
}
setCameraState(state: CameraState): void {
if (state.cameraType && state.cameraType !== this.getCurrentCameraType()) {
this.toggleCamera(state.cameraType)
}
this.activeCamera.position.copy(state.position)
this.controls?.target.copy(state.target)
if (
state.fov !== undefined &&
this.activeCamera instanceof THREE.PerspectiveCamera
) {
this.activeCamera.fov = state.fov
}
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
@@ -183,9 +199,40 @@ export class CameraManager implements CameraManagerInterface {
this.activeCamera.updateProjectionMatrix()
}
if (state.quaternion) {
const q = new THREE.Quaternion(
state.quaternion.x,
state.quaternion.y,
state.quaternion.z,
state.quaternion.w
)
const appliedUp = new THREE.Vector3(0, 1, 0).applyQuaternion(q)
this.activeCamera.up.copy(appliedUp)
this.customUp = appliedUp.clone()
this.usingCustomUp = true
this.eventManager.emitEvent('cameraUpStateChange', {
hasCustomUp: true,
usingCustomUp: true
})
}
this.controls?.update()
}
setUseCustomUp(use: boolean): void {
if (use && !this.customUp) return
if (use === this.usingCustomUp) return
const target =
use && this.customUp ? this.customUp : new THREE.Vector3(0, 1, 0)
this.activeCamera.up.copy(target)
this.usingCustomUp = use
this.controls?.update()
this.eventManager.emitEvent('cameraUpStateChange', {
hasCustomUp: this.customUp !== null,
usingCustomUp: this.usingCustomUp
})
}
handleResize(width: number, height: number): void {
const aspect = width / height
this.updateAspectRatio(aspect)

View File

@@ -136,7 +136,9 @@ function makeInstance() {
eventManager,
adapterRef: { current: null },
forceRender: vi.fn(),
handleResize: vi.fn()
handleResize: vi.fn(),
preRenderCallbacks: [],
postRenderCallbacks: []
})
return {

View File

@@ -13,6 +13,7 @@ type CameraStub = {
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
setUseCustomUp: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
@@ -48,6 +49,7 @@ function makeViewportInstance() {
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
setUseCustomUp: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
@@ -249,7 +251,6 @@ describe('Viewport3d', () => {
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewport.getOverlay()).toBe(overlay)
})
it('replacing an overlay detaches and disposes the prior one', () => {
@@ -261,18 +262,6 @@ describe('Viewport3d', () => {
expect(first.detach).toHaveBeenCalledOnce()
expect(first.dispose).toHaveBeenCalledOnce()
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(ctx.viewport.getOverlay()).toBe(second)
})
it('removeOverlay detaches and disposes the installed overlay', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
ctx.viewport.removeOverlay()
expect(overlay.detach).toHaveBeenCalledOnce()
expect(overlay.dispose).toHaveBeenCalledOnce()
expect(ctx.viewport.getOverlay()).toBeNull()
})
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
@@ -487,4 +476,77 @@ describe('Viewport3d', () => {
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
})
describe('render callback dispatch', () => {
interface CallbackAccess {
preRenderCallbacks: Array<() => void>
postRenderCallbacks: Array<() => void>
addPreRenderCallback(cb: () => void): () => void
addPostRenderCallback(cb: () => void): () => void
runPreRenderCallbacks(): void
runPostRenderCallbacks(): void
}
it('does not skip siblings when a post-render callback disposes itself', () => {
const vp = ctx.viewport as unknown as CallbackAccess
vp.postRenderCallbacks = []
const calls: string[] = []
const disposeA = vp.addPostRenderCallback(() => {
calls.push('a')
disposeA()
})
vp.addPostRenderCallback(() => calls.push('b'))
vp.runPostRenderCallbacks()
expect(calls).toEqual(['a', 'b'])
})
it('runs pre-render callbacks and stops after disposal', () => {
const vp = ctx.viewport as unknown as CallbackAccess
vp.preRenderCallbacks = []
const cb = vi.fn()
const dispose = vp.addPreRenderCallback(cb)
vp.runPreRenderCallbacks()
dispose()
vp.runPreRenderCallbacks()
expect(cb).toHaveBeenCalledOnce()
})
it('dispatches pre -> main scene -> post within a render cycle', () => {
const order: string[] = []
Object.assign(ctx.viewport, {
view: {
renderer: { setScissorTest: vi.fn() },
beginRender: () => order.push('begin'),
blit: vi.fn()
},
renderMainScene: () => order.push('main'),
viewHelperManager: { render: vi.fn() }
})
const vp = ctx.viewport as unknown as CallbackAccess & {
renderView(): void
}
vp.preRenderCallbacks = []
vp.postRenderCallbacks = []
vp.addPreRenderCallback(() => order.push('pre'))
vp.addPostRenderCallback(() => order.push('post'))
vp.renderView()
expect(order).toEqual(['begin', 'pre', 'main', 'post'])
})
})
describe('setUseCustomUp', () => {
it('delegates to the camera manager and forces a render', () => {
ctx.viewport.setUseCustomUp(true)
expect(ctx.cameraManager.setUseCustomUp).toHaveBeenCalledWith(true)
expect(ctx.forceRender).toHaveBeenCalled()
})
})
})

View File

@@ -183,7 +183,9 @@ export class Viewport3d {
private renderView(): void {
this.view.beginRender()
this.runPreRenderCallbacks()
this.renderMainScene()
this.runPostRenderCallbacks()
this.renderer.setScissorTest(false)
this.viewHelperManager.render(
@@ -194,6 +196,33 @@ export class Viewport3d {
this.view.blit()
}
private readonly preRenderCallbacks: Array<() => void> = []
private readonly postRenderCallbacks: Array<() => void> = []
addPreRenderCallback(cb: () => void): () => void {
this.preRenderCallbacks.push(cb)
return () => {
const i = this.preRenderCallbacks.indexOf(cb)
if (i >= 0) this.preRenderCallbacks.splice(i, 1)
}
}
addPostRenderCallback(cb: () => void): () => void {
this.postRenderCallbacks.push(cb)
return () => {
const i = this.postRenderCallbacks.indexOf(cb)
if (i >= 0) this.postRenderCallbacks.splice(i, 1)
}
}
private runPreRenderCallbacks(): void {
for (const cb of [...this.preRenderCallbacks]) cb()
}
private runPostRenderCallbacks(): void {
for (const cb of [...this.postRenderCallbacks]) cb()
}
protected tickPerFrame(delta: number): void {
this.overlay?.update?.(delta)
this.viewHelperManager.update(delta)
@@ -353,6 +382,11 @@ export class Viewport3d {
return this.cameraManager.getCameraState()
}
setUseCustomUp(use: boolean): void {
this.cameraManager.setUseCustomUp(use)
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.applyTargetSize(width, height)
this.handleResize()

View File

@@ -0,0 +1,71 @@
import type * as THREE from 'three'
import { RendererView } from '@/renderer/three/RendererView'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
import { LightingManager } from './LightingManager'
import { SceneManager } from './SceneManager'
import { ViewHelperManager } from './ViewHelperManager'
import { Viewport3d } from './Viewport3d'
import type { Viewport3dDeps } from './Viewport3d'
import type { Load3DOptions } from './interfaces'
function buildViewport3dDeps(container: HTMLElement): Viewport3dDeps {
const view = new RendererView(container)
const renderer = view.renderer
const eventManager = new EventManager()
let cameraManager: CameraManager
let controlsManager: ControlsManager
const getActiveCamera = (): THREE.Camera => cameraManager.activeCamera
const getControls = () => controlsManager.controls
const sceneManager = new SceneManager(
view,
getActiveCamera,
getControls,
eventManager
)
cameraManager = new CameraManager(renderer, eventManager)
controlsManager = new ControlsManager(
container,
cameraManager.activeCamera,
eventManager
)
cameraManager.setControls(controlsManager.controls)
const lightingManager = new LightingManager(sceneManager.scene, eventManager)
const viewHelperManager = new ViewHelperManager(
renderer,
getActiveCamera,
getControls,
eventManager
)
return {
view,
eventManager,
sceneManager,
cameraManager,
controlsManager,
lightingManager,
viewHelperManager
}
}
export function createViewport3d(
container: HTMLElement,
options?: Load3DOptions
): Viewport3d {
const viewport = new Viewport3d(
container,
buildViewport3dDeps(container),
options
)
viewport.start()
return viewport
}

View File

@@ -82,6 +82,8 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
hasCustomUp?: boolean
useCustomUp?: boolean
}
export interface LightConfig {
@@ -159,6 +161,7 @@ export interface CameraManagerInterface extends BaseManager {
setFOV(fov: number): void
setCameraState(state: CameraState): void
getCameraState(): CameraState
setUseCustomUp(use: boolean): void
handleResize(width: number, height: number): void
setControls(controls: OrbitControls): void
}

View File

@@ -24,3 +24,6 @@ export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
export const isLoad3dNode = (nodeType: string): boolean =>
LOAD3D_ALL_NODES.has(nodeType)
export const isThreeJsNode = (nodeType: string): boolean =>
isLoad3dNode(nodeType) || nodeType === 'CreateCameraInfo'

View File

@@ -15,7 +15,7 @@ import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
import { isLoad3dNode } from './load3d/nodeTypes'
import { isLoad3dNode, isThreeJsNode } from './load3d/nodeTypes'
let load3dExtensionsLoaded = false
let load3dExtensionsLoading: Promise<ComfyExtension[]> | null = null
@@ -39,7 +39,8 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
import('./saveMesh'),
import('./cameraInfo')
])
load3dExtensionsLoaded = true
return useExtensionStore().enabledExtensions.filter(
@@ -58,6 +59,8 @@ useExtensionService().registerExtension({
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) {
if (!isThreeJsNode(nodeData.name)) return
if (isLoad3dNode(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
@@ -68,14 +71,14 @@ useExtensionService().registerExtension({
modelFile[1].upload_subfolder = '3d'
}
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
// since invokeExtensionsAsync already captured the extensions snapshot
// before these new extensions were registered.
const newExtensions = await loadLoad3dExtensions()
for (const ext of newExtensions) {
await ext.beforeRegisterNodeDef?.(nodeType, nodeData, app)
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
// since invokeExtensionsAsync already captured the extensions snapshot
// before these new extensions were registered.
const newExtensions = await loadLoad3dExtensions()
for (const ext of newExtensions) {
await ext.beforeRegisterNodeDef?.(nodeType, nodeData, app)
}
}
})

View File

@@ -2052,6 +2052,19 @@
},
"load3d": {
"switchCamera": "Switch Camera",
"useCustomUp": "Restore camera up from input",
"useNaturalUp": "Reset camera up to Y",
"showGizmos": "Show gizmos",
"hideGizmos": "Hide gizmos",
"lookThrough": "Camera view",
"exitLookThrough": "Exit camera view",
"transformGizmo": {
"label": "Edit",
"none": "None",
"target": "Target",
"cameraTranslate": "Cam pos",
"cameraRotate": "Cam rot"
},
"showGrid": "Show Grid",
"backgroundColor": "Background Color",
"lightIntensity": "Light Intensity",
@@ -2254,6 +2267,7 @@
"cannotCreateSubgraph": "Cannot create subgraph",
"failedToConvertToSubgraph": "Failed to convert items to subgraph",
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer",
"failedToInitializeCameraInfoViewer": "Failed to initialize Camera Info viewer. Try reloading the page.",
"failedToLoadBackgroundImage": "Failed to load background image",
"failedToLoadModel": "Failed to load 3D model",
"modelLoadedSuccessfully": "3D model loaded successfully",
@@ -2473,6 +2487,11 @@
},
"credits": {
"activity": "Activity",
"insufficient": {
"memberTitle": "This workspace is out of credits",
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
"memberCta": "Ok, got it"
},
"credits": "Credits",
"yourCreditBalance": "Your credit balance",
"purchaseCredits": "Purchase Credits",
@@ -2882,6 +2901,32 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"billingStatus": {
"warning": {
"title": "Payment declined",
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
},
"paused": {
"title": "Subscription paused",
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
"outOfCredits": {
"title": "Out of credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
"addCredits": "Add credits",
"dismiss": "Dismiss"
},
"ending": {
"title": "Your team plan ends on {date}",
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan"
},
"updatePayment": "Update payment"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
@@ -3556,6 +3601,9 @@
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback",
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
"freeTierRunsExhausted": "No runs left",
"freeTierPartner": "Partner nodes need a paid plan",
"share": "Share",
"shareTooltip": "Share workflow"
},

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