Compare commits

...

16 Commits

Author SHA1 Message Date
nav-tej
645472b2bd Merge branch 'main' into nav/posthog-qa-reset-on-logout 2026-06-01 17:12:58 -07:00
Alexander Brown
9a86b33c77 fix: silence icon utility probe warnings (#12512)
## Summary

Silence false Iconify warning spam during Tailwind lint analysis while
preserving icon utility generation and real failure signals.

## Changes

- **What**: Local wrapper around `@iconify/tailwind4` that swallows the
noise from `better-tailwindcss/enforce-canonical-classes` probes.
Wrapper delegates icon resolution to upstream's `getDynamicCSSRules`;
only custom-comfy folder loading is reimplemented. `lucideStrokePlugin`
uses a `InvalidIconProbeError` sentinel so missing icons / missing deps
still surface.
- **Dependencies**: None.

## Background

Both upstreams have refused to address this:

- `better-tailwindcss`
([schoero/eslint-plugin-better-tailwindcss#377](https://github.com/schoero/eslint-plugin-better-tailwindcss/issues/377),
open) — owner declines to filter third-party warnings; says it should be
fixed in iconify.
- `@iconify/tailwind4`
([iconify/iconify#357](https://github.com/iconify/iconify/issues/357),
closed) — maintainer defends the `console.warn` as intentional; *"not
something plugin has control over"*.

Trigger path: `enforce-canonical-classes` → Tailwind
`canonicalizeCandidates()` → iconify `matchComponents` handler throws on
non-icon strings → upstream catches + `console.warn`.

## Review Focus

- Inline `loadComfyIconSet` in `iconifyDynamicPlugin.js` skips
`@iconify/tools`' `parseColors → currentColor` normalization. Verified
the comfy SVGs already use `currentColor` directly; multi-color icons
(bria, bytedance) keep their explicit fills as expected.
- `lucideStrokePlugin.js` catch is now type-discriminated — only
`InvalidIconProbeError` returns `{}`; `Cannot load icon set` and `Cannot
find icon` re-throw.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-01 21:57:13 +00:00
Matt Miller
faac0347aa fix(cloud/oauth): mint session cookie when resuming consent while already signed in (#12571)
## Problem

When a user is **already signed in** and a client (e.g. an MCP client)
starts the OAuth flow, the consent screen fails with:

> OAuth request failed. Please restart from the client app.

**Repro:** be signed in to Cloud, then trigger an OAuth authorization
from a client. Instead of the consent screen, the consent view renders
the generic error.

## Root cause

The consent challenge request (`GET /oauth/authorize`) is authenticated
by the Cloud **session cookie** — a distinct credential from the
Firebase client login that `isLoggedIn` reflects.

- The **post-login** resume path (`useOAuthPostLoginRedirect`) mints
that cookie via `createSessionOrThrow()` before navigating to consent.
- The **already-signed-in** path — the `cloud-login` / `cloud-signup`
`beforeEnter` guards in `onboardingCloudRoutes.ts`, which short-circuit
straight to consent when `isLoggedIn` is `true` — skipped that step.

With no session cookie the consent challenge request is unauthenticated,
so the server redirects it to login; `fetch` follows the redirect to an
HTML page, `response.json()` throws, and `OAuthConsentView` falls
through to `genericError`.

The `Comfy.Cloud.SessionCookie` extension's `createSession()` on
auth-resolve is fire-and-forget and races the redirect, so it doesn't
reliably cover this path.

## Fix

`oauthConsentRedirect()` now mints the session cookie
(`createSessionOrThrow()`) before redirecting an already-signed-in user
to consent — symmetric with the post-login path. Best-effort: on failure
it logs and still lands on the consent view so the user gets an
actionable message rather than a silently dropped flow.

## Test plan

- [x] New `onboardingCloudRoutes.test.ts`: resume → mints cookie then
routes to consent; no pending OAuth → `cloud-user-check` (no mint); mint
failure → still routes to consent so the view surfaces the error.
- [x] `vue-tsc` typecheck, eslint, oxlint clean.
- [ ] Manual: already signed in to Cloud, start OAuth from a client →
consent screen renders (no "OAuth request failed").
2026-06-01 21:53:50 +00:00
Shibo Zhou
71f4b28207 feat: enrich App Mode telemetry with view_mode, workflow_id, and is_app (#12543)
## Summary

Stamp App Mode telemetry with the properties needed to measure the App
Builder product metrics validly in PostHog. Three small, independent
enrichments on top of the App-Mode execution attribution.

## Changes

- **What**:
- `view_mode` on `execution_start` / `execution_success` /
`execution_error` (captured at queue time alongside `is_app_mode`). Lets
the North Star be `execution_success` where `view_mode='app'` — genuine
app runs, excluding `builder:arrange` builder-preview runs that bare
`is_app_mode` also counts.
- `workflow_id` on `app:workflow_saved` and `app:app_mode_opened`
(sources `workflow` / `template_url`) via a shared
`workflowTelemetryId()` helper; `storeJob` refactored onto it so save /
open / run events share one join key. Enables distinct-app counts,
activated apps (created → ≥1 successful run), and per-app quality.
- intrinsic `is_app` on `app:share_flow` `link_created` (from the
workflow's `initialMode`, not the share-time view) plus `workflow_id`;
`is_app` on `app:workflow_imported` / `opened` (from the loaded graph's
`extra.linearMode`). Enables virality by true app-ness and app-traffic
attribution.
- **Breaking**: none.
- **Dependencies**: none.

## Review Focus

- **The commits to review are the three after the foundation**:
`view_mode`, `workflow_id`, and `is_app`. The first commit in the diff
(`feat: attribute workflow executions to App Mode in telemetry`) is the
pre-existing foundation this builds on — its branch is not currently on
the remote, so this PR is based on `main` and carries it forward.
Reviewing per-commit is easiest.
- **Join-key consistency**: `workflowTelemetryId()` is the single
definition of the workflow id (`activeState.id ?? initialState.id`),
shared by the new save/open events and the existing execution events. A
divergence would silently break the created→run and opened→run joins.
Unit-tested.
- **Scope (YAGNI)**: `workflow_id` / `is_app` added only where a locked
metric consumes it — `share_flow` only on `link_created`;
`app_mode_opened` only on the `workflow` / `template_url` sources (not
`app_builder` / `keybind`).
- **No double serialize**: `app.ts` reuses a single
`rootGraph.serialize()` for both the `is_app` derivation and
`afterLoadNewGraph`.

Cloud-only (telemetry is tree-shaken from OSS builds). No UI changes.

---------

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-01 14:47:09 -07:00
imick-io
fef35e7dda fix(website): responsive fixes for pricing, product heroes, and cloud banner (#12570)
## Summary

Responsive polish: stack mobile pricing plans, rework the cloud product
hero so the illustration and text fit at all viewports, shrink the local
product hero illustration below xl, and reduce the cloud banner font
size on mobile.

## Changes

- **What**:
- `pricing/PriceSection.vue`: replace mobile tab-toggle pattern with
stacked plan cards; drop the `activePlanIndex` ref and tab buttons.
- `product/cloud/HeroSection.vue`: rework layout so illustration and
text fit across breakpoints (md/lg sizing, spacing, max-widths).
- `product/local/HeroSection.vue`: shrink illustration container below
xl (`max-w-xs`/`md:max-w-sm`/`lg:max-w-md`).
- `product/shared/CloudBannerSection.vue`: smaller font on mobile
(`text-sm` instead of `text-lg`).

## Review Focus

- Mobile pricing UX: confirm stacked cards are preferable to the prior
tab-toggle for our target viewports.
- Cloud hero layout at md/lg/xl breakpoints — check for overflow or
awkward spacing.

## Screenshots (if applicable)

<!-- Add before/after screenshots of the pricing page (mobile) and
product hero pages. -->

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-06-01 21:30:30 +00:00
nav-tej
e4d481f893 fix(telemetry): harden PostHog init — person_profiles, cookie_domain, before_send (#12479)
## Summary

Hardens PostHog initialization in the cloud app and website with three
missing config options identified during PostHog QA audit.

## Changes

- **What**: Adds `person_profiles: 'identified_only'`, `cookie_domain:
'.comfy.org'`, and `before_send` PII redaction hook to both PostHog init
calls
- **person_profiles**: Prevents anonymous Person records being created
in PostHog for pre-auth visitors — only creates Person after
`identify()` is called (matches website config which already had this)
- **cookie_domain**: Enables cross-subdomain session stitching across
`app.comfy.org`, `docs.comfy.org`, `www.comfy.org` — without this, each
subdomain creates a separate PostHog session
- **before_send**: Last-line PII guard — strips `email`, `prompt`,
`user_email`, `$email` from event properties before transmission. Placed
after `...serverConfig` spread so it cannot be overridden by remote
config.

## Review Focus

- `before_send` runs before GeoIP enrichment — deliberately does NOT
drop `$ip` since country-code targeting needs it for the pricing
experiment
- `person_profiles: 'identified_only'` + anonymous flag evaluation:
PostHog confirmed this still allows flag eval via `$anon_distinct_id`;
stitched retroactively on `identify()`
- `serverConfig` spread is before the three new fields — these take
precedence over any remote config values (intentional for
security/correctness)

Closes MAR-233, MAR-235, MAR-236

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miles <miles@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Miles Ryan <thedatalife@users.noreply.github.com>
2026-06-01 20:53:00 +00:00
Alexander Brown
da836cb681 chore: upgrade Astro to v6 (#12532)
## Summary

Upgrades the website app from Astro 5.10 to Astro 6.4.2.

## Changes

- **What**: Bumps `astro` (5.10 → 6.4.2), `@astrojs/vue` (5 → 6.0.1),
`@astrojs/sitemap` (3.7.1 → 3.7.3), and `@astrojs/check` (0.9.8 → 0.9.9)
in the workspace catalog. Refreshes `vite` in the lockfile from `8.0.0`
to `8.0.14` to clear a Vite/Rolldown "Not implemented" regression in dep
optimization that Astro v6 triggered on `8.0.0`.
- **Breaking**: None for our code. Audit against the [Astro v6 upgrade
guide](https://docs.astro.build/en/guides/upgrade-to/v6/) found no
breaking change applies:
  - Already on `<ClientRouter />` (no removed `<ViewTransitions />`)
- No content collections (`defineCollection`/`getCollection`) — legacy
removal n/a
- `getStaticPaths` functions don't access the deprecated `Astro` global
- No `<Image>`/`<Picture>`/`getImage()` usage — SVG rasterization &
client-throw n/a
  - No `import.meta.env.ASSETS_PREFIX`
- No file-extension endpoints (`*.xml.ts`/`*.json.ts`) so the
trailing-slash change is moot
  - No markdown content (heading-ID compat n/a)
- i18n config (`prefixDefaultLocale: false`, no
`redirectToDefaultLocale`) is compatible with the new
`redirectToDefaultLocale: false` default
  - No experimental flags enabled

## Review Focus

- The Vite lockfile bump from 8.0.0 → 8.0.14 inside the same `^8.0.0`
range. Without it, `astro check` on v6 hits `Not implemented` inside
Vite's `esbuildPlugin.generateBundle` via Rolldown.
- Sanity-check the audit above against any in-flight work that might add
content collections / `<Image>` etc.

## Verification

- `pnpm typecheck` → 0 errors
- `pnpm build` → 379 pages built
- `pnpm test:unit` → 85/85 passing
- `pnpm lint` → 0 errors (3 pre-existing warnings)
2026-06-01 20:38:13 +00:00
Deep Mehta
a549bd0123 fix: add static fallback favicon to index.html (#12537)
## Summary

cloud.comfy.org (and any route before the graph view mounts, e.g. the
login screen) renders no favicon. Add a static fallback so a favicon
always shows.

## Changes

- **What**: The favicon is only ever set at runtime —
`useFavicon('/assets/favicon.ico')` in `LayoutDefault.vue` and the
progress frames in `useProgressFavicon.ts`. Before that JS runs (first
paint, login screen, routes that don't mount
`GraphView`/`LayoutDefault`), no `<link rel="icon">` exists, and the
browser's default `/favicon.ico` request hits the SPA catch-all and
returns HTML, so no favicon renders. Added a single static `<link
rel="icon" href="/assets/favicon.ico">` (asset already exists) to
`index.html`.
- **Breaking**: none.

## Review Focus

- Single icon link is intentional: vueuse `useFavicon` overwrites the
`href` of every `link[rel*="icon"]` at runtime (including
`apple-touch-icon`), so the progress-favicon animation assumes one
controllable icon link. Adding multiple static links (svg/png) would get
their `href` clobbered to the progress PNG mid-generation and render
inconsistently — so this PR deliberately adds just the `.ico`.
- The progress-favicon still works: vueuse updates this same link's
`href`.
- Path matches what the app already uses at runtime
(`/assets/favicon.ico`).

## Screenshots (if applicable)
2026-06-01 20:31:16 +00:00
Alexander Brown
593586bbeb chore: Update CODEOWNERS (Should we just delete it?) (#12568)
It's requiring a lot of "Skip check" merges.
2026-06-01 19:56:19 +00:00
Terry Jia
7df62ca75e feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12545)
## Summary
Two dedicated 3D viewer extensions for the splat / point-cloud.

- Comfy.PreviewGaussianSplat targets backend node 'PreviewGaussianSplat'
(.ply / .spz / .splat / .ksplat).
- Comfy.PreviewPointCloud targets backend node 'PreviewPointCloud' (.ply
point clouds).

PLY auto-dispatch, no more user-facing engine choice for 3DGS:

**Please be aware that I have not yet implemented any UI optimizations
on the frontend for world models such as World Labs' Marble, no WSAD
controls, no scale optimization yet**

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

## Screenshots (if applicable)
ksplat file:
<img width="2714" height="1391" alt="image"
src="https://github.com/user-attachments/assets/9024db9d-20e9-44ea-ab14-500810d2946a"
/>
splat file:
<img width="2938" height="1410" alt="image"
src="https://github.com/user-attachments/assets/de768fa5-9d55-4560-9fb3-b218b96ea0c7"
/>
spz file:
<img width="1729" height="845" alt="image"
src="https://github.com/user-attachments/assets/cc09e568-77c9-45b3-a6cc-8f5d1062f3ec"
/>
ply (splat) file:
<img width="1702" height="843" alt="image"
src="https://github.com/user-attachments/assets/2a51c2ce-046b-4843-9e58-634bc45cbcce"
/>
ply (point cloud) file:
<img width="1701" height="842" alt="image"
src="https://github.com/user-attachments/assets/db75808e-3481-4ecc-8582-e4fec21163fd"
/>
2026-06-01 14:25:06 -04:00
nav
f4653b7365 fix(telemetry): handle logout before PostHog finishes initializing
If trackLogout fires before the dynamic posthog-js import resolves,
this.posthog?.reset(true) is a no-op and the reset is never applied
later — leaving a path for identity/session bleed across users on
shared browsers. Defer the reset to the init callback via a flag,
and clear the pre-logout event queue at logout time so events
queued before logout don't surface under the post-logout anonymous
distinct_id.
2026-06-01 08:09:19 -07:00
nav-tej
d99f08e4ea Merge branch 'main' into nav/posthog-qa-reset-on-logout 2026-06-01 07:57:16 -07:00
Dante
e16a0bfe82 fix(knip): narrow Playwright entrypoints so browser-test dead exports are reported (FE-717) (#12496)
## Summary

Narrow Knip's Playwright `entry` to actual spec files so dead exports in
browser-test fixtures are reported instead of being hidden by treating
every helper as an entrypoint.

## Changes

- **What**:
- `knip.config.ts`: Playwright `entry` changed from the broad
`['**/*.@(spec|test)…', 'browser_tests/**/*.ts']` to
`['browser_tests/**/*.@(spec|test).?(c|m)[jt]s?(x)']`.
`globalSetup`/`globalTeardown` stay covered via Knip's playwright config
resolution; fixtures remain in the project graph so their unused exports
surface.
- Resolved the 54 dead findings this exposed: over-exported symbols used
only within their own module are now module-private (dropped `export`,
no behavioral change); genuinely unreferenced fixtures were deleted
(asset/template `ALL_*` aggregators + orphaned `STABLE_*` data,
`TemplateHelper` distribution helpers + `generateTemplates`, dead
types/utils, and the unused `nodeDefinitions.ts` module).
- **Breaking**: none — test-only changes.

## Review Focus

- Deletions are limited to fixtures with zero importers on `main`
(verified via `pnpm knip`); the bulk of the diff is `export`-keyword
removal.
- Verified: `pnpm knip` (browser_tests clean), `pnpm typecheck`, `pnpm
typecheck:browser`, oxfmt/oxlint/eslint all pass.

Linear: FE-717
2026-06-01 07:53:29 +00:00
Dante
3a8ddfb6f1 fix: wrap long workflow name in Open shared workflow dialog (FE-828) (#12540)
## Summary

The "Open shared workflow" dialog rendered the workflow name in an
`<h2>` with no
wrapping control. A long, space-free name (e.g. a content-hash filename)
is a
single unbreakable "word", so with the default `overflow-wrap: normal`
it could
not wrap. It overflowed its box and, because PrimeVue's
`.p-dialog-content` is
`overflow-x: auto`, the dialog scrolled horizontally instead of
wrapping.

CDP measurement on the unfixed build (96-char name): dialog content
`scrollWidth 1336` vs `clientWidth 702` -> horizontal scroll. After
adding
`wrap-anywhere` to the heading: `scrollWidth 702 == clientWidth 702`,
name wraps
to multiple lines, full name still in the DOM.

### before
<img width="704" height="295" alt="before-dialog"
src="https://github.com/user-attachments/assets/ea05ab32-a80d-4210-951c-f43d595bd6eb"
/>

### after
<img width="704" height="359" alt="after-dialog"
src="https://github.com/user-attachments/assets/cbf3019e-5e71-4dba-a1fd-ea3586dd995a"
/>

## Changes

- `OpenSharedWorkflowDialogContent.vue`: add `wrap-anywhere` to the
workflow-name
`<h2>` so a long unbreakable name wraps within the dialog bounds instead
of
  forcing horizontal scroll. The parent already has `min-w-0`.
- Breaking: none

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| [`test:`
d9158d1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26678596015)
| 🔴 Red (failure) | Proves the test catches the bug — cloud
e2e failed at `expect(scrollWidth).toBeLessThanOrEqual(clientWidth +
1)`: received 1332, expected <= 703 |
| [`fix:`
08e75a1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/26678974321)
| 🟢 Green (success) | Proves the fix resolves it |

Fixes FE-828

## Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] E2E regression
`browser_tests/tests/dialogs/openSharedWorkflowDialog.spec.ts` (@cloud):
mocks a long-named shared workflow, asserts the dialog does not overflow
horizontally
- [x] Manual CDP verification (before/after above)
2026-06-01 07:53:16 +00:00
Dante
5a53df8d79 feat(dialog): migrate Manager dialog to Reka-UI (Phase 4) (#12403)
## Summary

Phase 4 of the dialog migration. Flips `useManagerDialog` onto the Reka
renderer added in Phase 0, with content sizing that matches the legacy
`.manager-dialog` CSS (1724px × 80vh, expanding to 2200×1320 above
3000px). Public API of `useManagerDialog` / `useDialogService` is
unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-576](https://linear.app/comfyorg/issue/FE-576/phase-4-migrate-manager-dialog-designer-review)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged), #12109
(Phase 2, merged), #12182 (Phase 3, **stacked PR base**)

> **Stacked on Phase 3**: this PR targets
`jaewon/fe-575-dialog-reka-migration-phase-3`. Rebase onto `main` after
#12182 lands.

## Changes

### `src/workbench/extensions/manager/composables/useManagerDialog.ts`
(+12)

| Field | Value | Reason |
| --- | --- | --- |
| `renderer` | `'reka'` | Flip onto the new path |
| `size` | `'full'` | Free `DialogContent` to take the contentClass
dimensions |
| `contentClass` | `w-[90vw] max-w-[1724px] sm:max-w-[1724px] h-[80vh]
max-h-[1026px] min-[3000px]:max-w-[2200px] min-[3000px]:max-h-[1320px]
rounded-2xl overflow-hidden` | Mirrors legacy `.manager-dialog` global
CSS exactly |
| `modal` | `false` | Manager hosts PrimeVue overlays (`SingleSelect`,
`SearchAutocomplete`-host scope, sort dropdown) teleported to body. Reka
modal trap disables their pointer-events. Same fix Phase 3 applied to
Settings |

Intentionally left for Phase 6: the global `.manager-dialog` CSS in
`GlobalDialog.vue` `<style>` and the matching `class="manager-dialog"`
on `BaseModalLayout`. Removing them here is a cascade-order risk (they
currently override `BaseModalLayout` size="lg" 1280px cap with 1724px);
Phase 6 owns the CSS-overrides cleanup pass.

### Tests

-
`src/workbench/extensions/manager/composables/useManagerDialog.test.ts`
**(new)** — 5 tests: renderer flip + Manager sizing, non-modal,
`initialTab` forwarding, `initialPackId` forwarding, `hide()` closes.

## Verification

### DOM probes (local dev, `useManagerDialog().show()` against ComfyUI
on :8189)

| Probe | Result |
| --- | --- |
| Manager dialog node | `[role="dialog"]` at z-1804, rect 1724×794 —
`contentClass` applied exactly |
| SingleSelect listbox | Teleported to `BODY > DIV > listbox`, z-3000 —
escapes Reka `overflow-hidden` |
| SearchAutocomplete | Uses Reka `ComboboxPortal` (`z-3000`,
`position="popper"`) — same teleport guarantee by construction |
| Stacked Reka confirm over Manager | Manager z-1804, confirm z-1806 —
vRekaZIndex orders correctly |
| ESC on stacked confirm | Top confirm closes, Manager remains open |

Screenshots from local verification will be attached as PR comments.

## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean for touched files
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent):
  - `useManagerDialog.test.ts` — 5/5
  - `src/workbench/extensions/manager/composables/` — 156/156
- `useSettingsDialog.test.ts` + `src/components/dialog/` (Phase 3
regression net) — 86/86
- [ ] CI Playwright matrix
- [x] Manual verification on a backend (ComfyUI :8189)

## Public API impact

None. `useManagerDialog().show(initialTab?, initialPackId?)` keeps the
same signature.

## Out of scope (later phases)

- `ConfirmDialog` callers — Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue`
(incl. `.manager-dialog`) — Phase 6 (FE-578)
- Designer pass on Manager dimensions (FE-576 acceptance #1) — owner
Jaewon, async with this PR

## Review focus

1. **Sizing translated literally** — `contentClass` mirrors the existing
`.manager-dialog` CSS rule (height + max-width + max-height + 3000px
breakpoint). Net visible should be byte-identical to today. Worth a
designer pass per FE-576 acceptance criteria.
2. **`modal: false` rationale** — same as Phase 3 Settings: Manager's
PrimeVue-overlay children break under Reka modal focus trap. Acceptance
#2 ("Install/uninstall flows behave identically") is preserved because
no overlay component changed; only the outer dialog renderer.
3. **Phase 6 deferred cleanup** — `.manager-dialog` CSS rule +
`class="manager-dialog"` on `BaseModalLayout` are kept on purpose. They
override `BaseModalLayout size="lg"`'s 1280px cap with 1724px on small
viewports; removing them naively regresses width. Phase 6 will replace
via `size` prop or new variant.

## screenshot 

<img width="1440" height="828" alt="keybinding-panel"
src="https://github.com/user-attachments/assets/c8e4ae15-860c-4f61-b48f-795f5e24912d"
/>
<img width="1440" height="828" alt="nested-modify-keybinding"
src="https://github.com/user-attachments/assets/a6890183-be0f-4831-919e-4edb2ee2bb04"
/>
<img width="1920" height="992" alt="phase4-manager-node-pack-dropdown"
src="https://github.com/user-attachments/assets/e4f97486-8b89-408a-9f83-92fbbfd5c29b"
/>
<img width="1440" height="828" alt="settings-dialog-reka"
src="https://github.com/user-attachments/assets/7764ac51-703e-40a9-9ff8-af664b9ad11a"
/>


## Test plan

- [x] Unit: 5/5 new + 156/156 manager composables + 86/86 adjacent
- [ ] CI: full Vitest + Playwright matrix
- [x] Manual on a backend:
- Open Manager (nav, search filter, install button visible, scroll grid)
- SingleSelect (Node Pack) dropdown not clipped by dialog
`overflow-hidden`
  - Stacked Reka confirm over Manager renders above with correct z-index
  - ESC closes only the top dialog

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-01 13:41:53 +09:00
Nav Singh
c957913c71 fix(telemetry): call posthog.reset(true) on logout to prevent session bleeding
Closes MAR-234.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:14:36 -07:00
108 changed files with 3360 additions and 1892 deletions

View File

@@ -1,95 +1,60 @@
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Topbar
/src/components/topbar/ @pythongosssss
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
# Legacy UI
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @comfy_frontend_devs
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
/src/components/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
# Image Crop
/src/extensions/core/imageCrop.ts @jtydhr88
/src/components/imagecrop/ @jtydhr88
/src/composables/useImageCrop.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
/src/extensions/core/imageCrop.ts @jtydhr88 @comfy_frontend_devs
/src/components/imagecrop/ @jtydhr88 @comfy_frontend_devs
/src/composables/useImageCrop.ts @jtydhr88 @comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @comfy_frontend_devs
# Image Compare
/src/extensions/core/imageCompare.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
/src/extensions/core/imageCompare.ts @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
# Painter
/src/extensions/core/painter.ts @jtydhr88
/src/components/painter/ @jtydhr88
/src/composables/painter/ @jtydhr88
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
/src/extensions/core/painter.ts @jtydhr88 @comfy_frontend_devs
/src/components/painter/ @jtydhr88 @comfy_frontend_devs
/src/composables/painter/ @jtydhr88 @comfy_frontend_devs
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @comfy_frontend_devs
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @comfy_frontend_devs
# GLSL
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @comfy_frontend_devs
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/extensions/core/load3dLazy.ts @jtydhr88
/src/extensions/core/load3d/ @jtydhr88
/src/components/load3d/ @jtydhr88
/src/composables/useLoad3d.ts @jtydhr88
/src/composables/useLoad3d.test.ts @jtydhr88
/src/composables/useLoad3dDrag.ts @jtydhr88
/src/composables/useLoad3dDrag.test.ts @jtydhr88
/src/composables/useLoad3dViewer.ts @jtydhr88
/src/composables/useLoad3dViewer.test.ts @jtydhr88
/src/services/load3dService.ts @jtydhr88
/src/extensions/core/load3d.ts @jtydhr88 @comfy_frontend_devs
/src/extensions/core/load3dLazy.ts @jtydhr88 @comfy_frontend_devs
/src/extensions/core/load3d/ @jtydhr88 @comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3d.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3d.test.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dDrag.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dViewer.ts @jtydhr88 @comfy_frontend_devs
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @comfy_frontend_devs
/src/services/load3dService.ts @jtydhr88 @comfy_frontend_devs
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @comfy_frontend_devs
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
/src/platform/assets/mappings/ @deepme987 @comfy_frontend_devs
# LLM Instructions (blank on purpose)
.claude/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -2,7 +2,6 @@
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
@@ -116,8 +115,6 @@ const plans: PricingPlan[] = [
const standardPlans = plans.filter((p) => !p.isEnterprise)
const enterprisePlan = plans.find((p) => p.isEnterprise)!
const activePlanIndex = ref(0)
</script>
<template>
@@ -134,28 +131,7 @@ const activePlanIndex = ref(0)
</p>
</div>
<!-- Mobile plan tabs -->
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"
:class="
cn(
'shrink-0 rounded-full px-4 py-2 text-xs font-bold tracking-wider transition-colors',
activePlanIndex === index
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-transparency-white-t4 text-primary-comfy-canvas'
)
"
@click="activePlanIndex = index"
>
<span class="ppformula-text-center">
{{ t(plan.labelKey, locale) }}
</span>
</button>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
<div
:class="
cn(
@@ -273,13 +249,9 @@ const activePlanIndex = ref(0)
</PricingTierCard>
</div>
<!-- Mobile: single plan view -->
<div class="lg:hidden">
<div
v-for="(plan, index) in plans"
:key="plan.id"
:class="cn('flex-col', activePlanIndex !== index ? 'hidden' : 'flex')"
>
<!-- Mobile: stacked plans -->
<div class="flex flex-col gap-8 lg:hidden">
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
<!-- Main info card -->
<div class="bg-transparency-white-t4 rounded-3xl p-6">
<!-- Label + badge -->

View File

@@ -11,14 +11,12 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-visible lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto mb-12 flex flex-col items-center overflow-hidden px-4 md:flex-row md:overflow-visible md:pt-20 lg:items-center lg:space-x-20"
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-125 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-10 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<div class="pointer-events-none mx-auto w-full flex-1 md:-translate-x-20">
<svg
class="block size-full overflow-visible"
class="mx-auto block size-full max-w-lg overflow-visible md:ml-auto md:scale-125"
viewBox="50 50 900 900"
fill="none"
aria-hidden="true"
@@ -378,9 +376,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</div>
<!-- Text -->
<div
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[25%] lg:px-20 lg:py-14"
>
<div class="relative z-10 lg:flex-1">
<ProductHeroBadge text="CLOUD" />
<h1
@@ -390,7 +386,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</h1>
<p
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
class="text-primary-comfy-canvas mt-6 max-w-lg text-sm lg:mt-6 lg:text-base"
>
{{ t('cloud.hero.subtitle', locale) }}
</p>

View File

@@ -168,7 +168,7 @@ onUnmounted(() => {
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div
class="aspect-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-550/800 w-4/5 max-w-xs self-center overflow-visible md:max-w-sm lg:pointer-events-none lg:z-1 lg:-mr-12 lg:max-w-md lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"

View File

@@ -12,7 +12,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
>
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
class="text-primary-comfy-canvas relative z-10 text-sm font-semibold lg:text-sm lg:font-normal"
>
<span class="whitespace-nowrap">
{{ t('download.cloud.prefix', locale) }}

View File

@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
mockInit: vi.fn(),
mockCapture: vi.fn()
}))
vi.mock('posthog-js', () => ({
default: {
init: hoisted.mockInit,
capture: hoisted.mockCapture
}
}))
describe('initPostHog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
const { initPostHog } = await import('./posthog')
initPostHog()
expect(hoisted.mockInit).toHaveBeenCalledOnce()
const initOptions = hoisted.mockInit.mock.calls[0][1]
expect(initOptions.person_profiles).toBe('identified_only')
expect(typeof initOptions.before_send).toBe('function')
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: { email: 'd@example.com', name: 'keep me' },
$set_once: { $email: 'e@example.com', plan: 'free' }
}
const result = initOptions.before_send(event)
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('$email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})

View File

@@ -1,5 +1,7 @@
import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -18,7 +20,9 @@ export function initPostHog() {
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
person_profiles: 'identified_only',
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
before_send: createPostHogBeforeSend()
})
initialized = true
} catch (error) {

View File

@@ -1,7 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import { test as base } from '@playwright/test'
export class UserSelectPage {
class UserSelectPage {
public readonly selectionUrl: string
public readonly container: Locator
public readonly newUserInput: Locator

View File

@@ -18,7 +18,7 @@ class ShortcutsTab {
}
}
export class LogsTab {
class LogsTab {
readonly tab: Locator
readonly terminalRoot: Locator
readonly terminalHost: Locator

View File

@@ -1,7 +1,7 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
class ComfyNodeSearchFilterSelectionPanel {
readonly root: Locator
readonly header: Locator

View File

@@ -8,8 +8,6 @@ import type { Position } from '@e2e/fixtures/types'
const { searchBoxV2 } = TestIds
export type { RootCategoryId }
export class ComfyNodeSearchBoxV2 {
readonly dialog: Locator
readonly input: Locator

View File

@@ -2,8 +2,3 @@ export interface Position {
x: number
y: number
}
export interface Size {
width: number
height: number
}

View File

@@ -86,46 +86,6 @@ export const STABLE_LORA: Asset = createModelAsset({
updated_at: '2025-02-20T14:00:00Z'
})
export const STABLE_LORA_2: Asset = createModelAsset({
id: 'test-lora-002',
name: 'add_detail_v2.safetensors',
size: 226_492_416,
tags: ['models', 'loras'],
user_metadata: {
base_model: 'sd15',
description: 'Add Detail LoRA v2'
},
created_at: '2025-02-25T11:00:00Z',
updated_at: '2025-02-25T11:00:00Z'
})
export const STABLE_VAE: Asset = createModelAsset({
id: 'test-vae-001',
name: 'sdxl_vae.safetensors',
size: 334_641_152,
tags: ['models', 'vae'],
user_metadata: {
base_model: 'sdxl',
description: 'SDXL VAE'
},
created_at: '2025-01-18T16:00:00Z',
updated_at: '2025-01-18T16:00:00Z'
})
export const STABLE_EMBEDDING: Asset = createModelAsset({
id: 'test-embedding-001',
name: 'bad_prompt_v2.pt',
size: 32_768,
mime_type: 'application/x-pytorch',
tags: ['models', 'embeddings'],
user_metadata: {
base_model: 'sd15',
description: 'Negative Embedding: Bad Prompt v2'
},
created_at: '2025-02-01T09:30:00Z',
updated_at: '2025-02-01T09:30:00Z'
})
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
id: 'test-input-001',
name: 'reference_photo.png',
@@ -136,26 +96,6 @@ export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
updated_at: '2025-03-01T09:00:00Z'
})
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
id: 'test-input-002',
name: 'mask_layer.png',
size: 1_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-05T10:00:00Z',
updated_at: '2025-03-05T10:00:00Z'
})
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
id: 'test-input-003',
name: 'clip_720p.mp4',
size: 15_728_640,
mime_type: 'video/mp4',
tags: ['input'],
created_at: '2025-03-08T14:30:00Z',
updated_at: '2025-03-08T14:30:00Z'
})
export const STABLE_OUTPUT: Asset = createOutputAsset({
id: 'test-output-001',
name: 'ComfyUI_00001_.png',
@@ -166,31 +106,6 @@ export const STABLE_OUTPUT: Asset = createOutputAsset({
updated_at: '2025-03-10T12:00:00Z'
})
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
id: 'test-output-002',
name: 'ComfyUI_00002_.png',
size: 3_670_016,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:05:00Z',
updated_at: '2025-03-10T12:05:00Z'
})
export const ALL_MODEL_FIXTURES: Asset[] = [
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2,
STABLE_LORA,
STABLE_LORA_2,
STABLE_VAE,
STABLE_EMBEDDING
]
export const ALL_INPUT_FIXTURES: Asset[] = [
STABLE_INPUT_IMAGE,
STABLE_INPUT_IMAGE_2,
STABLE_INPUT_VIDEO
]
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
const CHECKPOINT_NAMES = [
'sd_xl_base_1.0.safetensors',
'v1-5-pruned-emaonly.safetensors',

View File

@@ -1,155 +0,0 @@
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
/**
* Base node definitions covering the default workflow.
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
*/
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
KSampler: {
input: {
required: {
model: ['MODEL', {}],
seed: [
'INT',
{
default: 0,
min: 0,
max: 0xfffffffffffff,
control_after_generate: true
}
],
steps: ['INT', { default: 20, min: 1, max: 10000 }],
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
positive: ['CONDITIONING', {}],
negative: ['CONDITIONING', {}],
latent_image: ['LATENT', {}]
},
optional: {
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'KSampler',
display_name: 'KSampler',
description: 'Samples latents using the provided model and conditioning.',
category: 'sampling',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
{}
]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: 'Loads a diffusion model checkpoint.',
category: 'loaders',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
CLIPTextEncode: {
input: {
required: {
text: ['STRING', { multiline: true, dynamicPrompts: true }],
clip: ['CLIP', {}]
}
},
output: ['CONDITIONING'],
output_is_list: [false],
output_name: ['CONDITIONING'],
name: 'CLIPTextEncode',
display_name: 'CLIP Text Encode (Prompt)',
description: 'Encodes a text prompt using a CLIP model.',
category: 'conditioning',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
EmptyLatentImage: {
input: {
required: {
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'EmptyLatentImage',
display_name: 'Empty Latent Image',
description: 'Creates an empty latent image of the specified dimensions.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
VAEDecode: {
input: {
required: {
samples: ['LATENT', {}],
vae: ['VAE', {}]
}
},
output: ['IMAGE'],
output_is_list: [false],
output_name: ['IMAGE'],
name: 'VAEDecode',
display_name: 'VAE Decode',
description: 'Decodes latent images back into pixel space.',
category: 'latent',
output_node: false,
python_module: 'nodes',
deprecated: false,
experimental: false
},
SaveImage: {
input: {
required: {
images: ['IMAGE', {}],
filename_prefix: ['STRING', { default: 'ComfyUI' }]
}
},
output: [],
output_is_list: [],
output_name: [],
name: 'SaveImage',
display_name: 'Save Image',
description: 'Saves images to the output directory.',
category: 'image',
output_node: true,
python_module: 'nodes',
deprecated: false,
experimental: false
}
}
export function createMockNodeDefinitions(
overrides?: Record<string, ComfyNodeDef>
): Record<string, ComfyNodeDef> {
const base = structuredClone(baseNodeDefinitions)
return overrides ? { ...base, ...overrides } : base
}

View File

@@ -2,11 +2,6 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
@@ -31,33 +26,3 @@ export function mockTemplateIndex(
}
]
}
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
name: 'cloud-stable',
title: 'Cloud Stable',
includeOnDistributions: [Cloud]
})
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
name: 'desktop-stable',
title: 'Desktop Stable',
includeOnDistributions: [Desktop]
})
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
name: 'local-stable',
title: 'Local Stable',
includeOnDistributions: [Local]
})
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
name: 'unrestricted-stable',
title: 'Unrestricted Stable'
})
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
STABLE_CLOUD_TEMPLATE,
STABLE_DESKTOP_TEMPLATE,
STABLE_LOCAL_TEMPLATE,
STABLE_UNRESTRICTED_TEMPLATE
]

View File

@@ -11,7 +11,7 @@ import {
generateOutputAssets
} from '@e2e/fixtures/data/assetFixtures'
export interface MutationRecord {
interface MutationRecord {
endpoint: string
method: string
url: string
@@ -23,7 +23,7 @@ interface PaginationOptions {
total: number
hasMore: boolean
}
export interface AssetConfig {
interface AssetConfig {
readonly assets: ReadonlyMap<string, Asset>
readonly pagination: PaginationOptions | null
readonly uploadResponse: Record<string, unknown> | null
@@ -33,7 +33,7 @@ function emptyConfig(): AssetConfig {
return { assets: new Map(), pagination: null, uploadResponse: null }
}
export type AssetOperator = (config: AssetConfig) => AssetConfig
type AssetOperator = (config: AssetConfig) => AssetConfig
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
const merged = new Map(config.assets)

View File

@@ -26,7 +26,7 @@ const historyRoutePattern = /\/api\/history$/
* The sidebar filter ultimately matches on the filename extension, so the
* fixture also picks an extension-appropriate filename for each kind.
*/
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
images: 'png',
@@ -134,16 +134,6 @@ export function createJobsWithExecutionTimes(
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
return Array.from(
{ length: count },
(_, i) =>
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
)
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {

View File

@@ -7,7 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
type ReleaseNote = components['schemas']['ReleaseNote']
export type HelpMenuItemKey =
type HelpMenuItemKey =
| 'feedback'
| 'help'
| 'docs'
@@ -17,7 +17,7 @@ export type HelpMenuItemKey =
| 'update-comfyui'
| 'more'
export class HelpCenterHelper {
class HelpCenterHelper {
public readonly button: Locator
public readonly popup: Locator
public readonly backdrop: Locator

View File

@@ -7,9 +7,9 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
const MASK_CANVAS_INDEX = 2
const RGB_CANVAS_INDEX = 1
export type BrushSliderLabel = 'thickness'
type BrushSliderLabel = 'thickness'
export class MaskEditorHelper {
class MaskEditorHelper {
constructor(private comfyPage: ComfyPage) {}
private get page() {

View File

@@ -9,7 +9,7 @@ const modelFoldersRoutePattern = /\/api\/experiment\/models$/
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
export interface MockModelMetadata {
interface MockModelMetadata {
'modelspec.title'?: string
'modelspec.author'?: string
'modelspec.architecture'?: string
@@ -18,14 +18,11 @@ export interface MockModelMetadata {
'modelspec.tags'?: string
}
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
function createMockModelFolders(names: string[]): ModelFolderInfo[] {
return names.map((name) => ({ name, folders: [] }))
}
export function createMockModelFiles(
filenames: string[],
pathIndex = 0
): ModelFile[] {
function createMockModelFiles(filenames: string[], pathIndex = 0): ModelFile[] {
return filenames.map((name) => ({ name, pathIndex }))
}

View File

@@ -43,7 +43,7 @@ const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
token: 'mock-upload-token'
}
export class PublishApiHelper {
class PublishApiHelper {
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
import { SubgraphBreadcrumbPanel } from '@e2e/fixtures/components/SubgraphBreadcrumbPanel'
export class SubgraphBreadcrumbHelper {
class SubgraphBreadcrumbHelper {
readonly panel: SubgraphBreadcrumbPanel
constructor(public readonly page: Page) {

View File

@@ -4,33 +4,9 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
/**
* Generate N deterministic templates, optionally restricted to a distribution.
*
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
* for static test data with no executable fixture logic.
*/
function generateTemplates(
count: number,
distribution?: TemplateIncludeOnDistributionEnum
): TemplateInfo[] {
const slug = distribution ?? 'unrestricted'
return Array.from({ length: count }, (_, i) =>
makeTemplate({
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
title: `Generated ${slug} ${i + 1}`,
...(distribution ? { includeOnDistributions: [distribution] } : {})
})
)
}
export interface TemplateConfig {
interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
}
@@ -39,7 +15,7 @@ function emptyConfig(): TemplateConfig {
return { templates: [], index: null }
}
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
type TemplateOperator = (config: TemplateConfig) => TemplateConfig
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
return templates.map((t) => structuredClone(t))
@@ -62,46 +38,6 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
return (config) => addTemplates(config, templates)
}
export function withTemplate(template: TemplateInfo): TemplateOperator {
return (config) => addTemplates(config, [template])
}
export function withCloudTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
)
}
export function withDesktopTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
)
}
export function withLocalTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
)
}
export function withUnrestrictedTemplates(count: number): TemplateOperator {
return (config) => addTemplates(config, generateTemplates(count))
}
/**
* Override the index payload entirely. Useful when a test needs a custom
* `WorkflowTemplates[]` shape (e.g. multiple modules).
*/
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
return (config) => ({ ...config, index })
}
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null

View File

@@ -121,7 +121,7 @@ export function createRouteMockJob({
}
}
export class JobsRouteMocker {
class JobsRouteMocker {
constructor(private readonly page: Page) {}
async mockJobsHistory(

View File

@@ -303,12 +303,3 @@ export const TestIds = {
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
}
} as const
export type TestId<K extends keyof typeof TestIds> = Exclude<
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
(...args: never[]) => string
>
export type TestIdValue = {
[K in keyof typeof TestIds]: TestId<K>
}[keyof typeof TestIds]

View File

@@ -19,7 +19,7 @@ export const sharedWorkflowImportScenario = {
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'

View File

@@ -3,9 +3,7 @@ import type { Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
export type { CanvasRect }
export interface MeasureResult {
interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}

View File

@@ -202,7 +202,7 @@ class NodeSlotReference {
}
}
export class NodeWidgetReference {
class NodeWidgetReference {
constructor(
readonly index: number,
readonly node: NodeReference

View File

@@ -3,7 +3,7 @@ import { join } from 'path'
import type { PerfMeasurement } from '@e2e/fixtures/helpers/PerformanceHelper'
export interface PerfReport {
interface PerfReport {
timestamp: string
gitSha: string
branch: string

View File

@@ -20,9 +20,7 @@ function previewExposureToEntry(
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
export function isPromotedWidgetSource(
value: unknown
): value is PromotedWidgetSource {
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
return (
!!value &&
typeof value === 'object' &&
@@ -33,7 +31,7 @@ export function isPromotedWidgetSource(
)
}
export function isNodeProperty(value: unknown): value is NodeProperty {
function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
export interface SlotMeasurement {
interface SlotMeasurement {
key: string
offsetX: number
offsetY: number
}
export interface NodeSlotData {
interface NodeSlotData {
nodeId: string
nodeW: number
nodeH: number

View File

@@ -0,0 +1,68 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
const shareId = 'fe828-long-name'
// Unbroken, space-free name (mimics a content-hash workflow name) that cannot
// wrap at whitespace and previously forced the dialog to scroll horizontally.
const longWorkflowName =
'c23df0133afe9cf61a9c0e3b1f5d8a7e6429bd14f0a3c8e2d9b7165430fedcba99887766554433221100ffeeddccbbaa'
const longNameWorkflowResponse: SharedWorkflowResponse = {
share_id: shareId,
workflow_id: 'fe828-long-name-workflow',
name: longWorkflowName,
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: []
},
assets: []
}
async function mockLongNameSharedWorkflow(page: Page): Promise<void> {
await page.route(`**/workflows/published/${shareId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(longNameWorkflowResponse)
})
})
}
const test = comfyPageFixture
test.describe('Open shared workflow dialog', { tag: '@cloud' }, () => {
test('wraps a long workflow name instead of scrolling horizontally', async ({
comfyPage
}) => {
const { page } = comfyPage
await mockLongNameSharedWorkflow(page)
await comfyPage.setup({ clearStorage: false, url: `/?share=${shareId}` })
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
const heading = dialog.locator('main h2')
await expect(heading).toHaveText(longWorkflowName)
const { scrollWidth, clientWidth } = await dialog.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth
}))
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
})
})

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<title>ComfyUI</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"

View File

@@ -73,7 +73,7 @@ const config: KnipConfig = {
},
playwright: {
config: ['playwright?(.*).config.ts'],
entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
entry: ['browser_tests/**/*.@(spec|test).?(c|m)[jt]s?(x)']
},
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind4": "catalog:",
"@iconify/tools": "catalog:",
"@iconify/utils": "catalog:",
"tailwindcss-primeui": "catalog:",
"tw-animate-css": "catalog:"

View File

@@ -0,0 +1,55 @@
import {
cleanupSVG,
importDirectorySync,
isEmptyColor,
parseColors,
runSVGO
} from '@iconify/tools'
import { resolve } from 'node:path'
export const COMFY_ICON_PREFIX = 'comfy'
const COMFY_ICONS_DIR = resolve(import.meta.dirname, '../icons')
let cached
/**
* Load the comfy icon folder as a normalized Iconify icon set.
*
* Mirrors the pipeline that `@plugin "@iconify/tailwind4" { from-folder(...) }`
* runs internally so monotone hardcoded colors become `currentColor` and
* outer-svg attributes like `fill="none"` survive the body extraction.
*/
export function loadComfyIconSet() {
if (cached) return cached
const iconSet = importDirectorySync(COMFY_ICONS_DIR)
iconSet.forEachSync((name, type) => {
if (type !== 'icon') return
const svg = iconSet.toSVG(name)
if (!svg) {
iconSet.remove(name)
return
}
try {
cleanupSVG(svg)
const palette = parseColors(svg)
const colors = palette.colors.filter(
(color) => typeof color === 'string' || !isEmptyColor(color)
)
const totalColors = colors.length + (palette.hasUnsetColor ? 1 : 0)
if (totalColors < 2) {
parseColors(svg, {
defaultColor: 'currentColor',
callback: (_attr, colorStr, color) =>
!color || isEmptyColor(color) ? colorStr : 'currentColor'
})
}
runSVGO(svg)
iconSet.fromSVG(name, svg)
} catch {
iconSet.remove(name)
}
})
cached = iconSet.export()
return cached
}

View File

@@ -0,0 +1,23 @@
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
import plugin from 'tailwindcss/plugin'
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
const SCALE = 1.2
const options = {
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
scale: SCALE
}
export default plugin(({ matchComponents }) => {
matchComponents({
icon: (icon) => {
try {
return getDynamicCSSRules(icon, options)
} catch {
return {}
}
}
})
})

View File

@@ -1,11 +1,13 @@
import plugin from 'tailwindcss/plugin'
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
import { matchIconName } from '@iconify/utils/lib/icon/name'
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
import plugin from 'tailwindcss/plugin'
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
/**
* Tailwind 4 plugin that provides lucide icon variants with configurable
* stroke-width via class prefix.
* Tailwind 4 plugin that provides icon variants with configurable
* stroke-width via class prefix. Supports lucide and comfy icon sets.
*
* Usage in CSS:
* @plugin "./lucideStrokePlugin.js";
@@ -13,25 +15,40 @@ import { matchIconName } from '@iconify/utils/lib/icon/name'
* Usage in templates:
* <i class="icon-s1-[lucide--settings]" /> <!-- stroke-width: 1 -->
* <i class="icon-s1.5-[lucide--settings]" /> <!-- stroke-width: 1.5 -->
* <i class="icon-s2.5-[lucide--settings]" /> <!-- stroke-width: 2.5 -->
* <i class="icon-s2.5-[comfy--workflow]" /> <!-- stroke-width: 2.5 -->
*
* The default class remains stroke-width: 2.
* The plain `icon-[...]` class keeps each icon's native stroke-width.
*/
const STROKE_WIDTHS = ['1', '1.3', '1.5', '2', '2.5']
const LUCIDE_PREFIX = 'lucide'
const SUPPORTED_PREFIXES = new Set([LUCIDE_PREFIX, COMFY_ICON_PREFIX])
const SCALE = 1.2
const STROKE_WIDTH_ATTR_RE = /stroke-width="[^"]*"/g
class InvalidIconProbeError extends Error {}
function resolveIconSet(prefix) {
if (prefix === COMFY_ICON_PREFIX) return loadComfyIconSet()
return loadIconSet(prefix)
}
function getDynamicCSSRulesWithStroke(icon, strokeWidth) {
const nameParts = icon.split(/--|:/)
if (nameParts.length !== 2) {
throw new Error(`Invalid icon name: "${icon}"`)
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
}
const [prefix, name] = nameParts
if (!(prefix.match(matchIconName) && name.match(matchIconName))) {
throw new Error(`Invalid icon name: "${icon}"`)
if (!SUPPORTED_PREFIXES.has(prefix)) {
throw new InvalidIconProbeError(`Unsupported icon prefix: "${prefix}"`)
}
const iconSet = loadIconSet(prefix)
if (!(prefix.match(matchIconName) && name.match(matchIconName))) {
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
}
const iconSet = resolveIconSet(prefix)
if (!iconSet) {
throw new Error(
`Cannot load icon set for "${prefix}". Install "@iconify-json/${prefix}" as dev dependency?`
@@ -40,7 +57,7 @@ function getDynamicCSSRulesWithStroke(icon, strokeWidth) {
const generated = getIconsCSSData(iconSet, [name], {
iconSelector: '.icon',
customise: (content) =>
content.replaceAll('stroke-width="2"', `stroke-width="${strokeWidth}"`)
content.replace(STROKE_WIDTH_ATTR_RE, `stroke-width="${strokeWidth}"`)
})
if (generated.css.length !== 1) {
throw new Error(`Cannot find "${icon}". Bad icon name?`)
@@ -62,8 +79,8 @@ export default plugin(({ matchComponents }) => {
try {
return getDynamicCSSRulesWithStroke(icon, sw)
} catch (err) {
console.warn(err.message)
return {}
if (err instanceof InvalidIconProbeError) return {}
throw err
}
}
})

View File

@@ -8,10 +8,7 @@
@plugin 'tailwindcss-primeui';
@plugin "@iconify/tailwind4" {
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
@plugin "./iconifyDynamicPlugin.js";
@plugin "./lucideStrokePlugin.js";

View File

@@ -7,7 +7,8 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts"
"./networkUtil": "./src/networkUtil.ts",
"./piiUtil": "./src/piiUtil.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { createPostHogBeforeSend } from './piiUtil'
describe('createPostHogBeforeSend', () => {
const beforeSend = createPostHogBeforeSend()
it('returns null for null input', () => {
expect(beforeSend(null)).toBeNull()
})
it('strips all PII keys from properties, $set, and $set_once', () => {
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: {
email: 'd@example.com',
user_email: 'e@example.com',
$email: 'f@example.com',
name: 'keep me'
},
$set_once: {
email: 'g@example.com',
plan: 'free'
}
}
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('handles missing property bags gracefully', () => {
const event = { properties: { email: 'a@example.com', safe: true } }
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).toHaveProperty('safe', true)
expect(result.$set).toBeUndefined()
expect(result.$set_once).toBeUndefined()
})
})

View File

@@ -0,0 +1,35 @@
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
function stripPiiKeys(obj?: Record<string, unknown>): void {
if (!obj) return
for (const key of PII_KEYS) {
delete obj[key]
}
}
/**
* PostHog before_send hook that strips PII from all three property bags
* an event can carry: properties, $set, and $set_once.
*
* posthog.identify(id, { email }) lands in $set, not properties, so all
* three bags must be sanitized.
*
* Ref: posthog.com/tutorials/web-redact-properties
*/
interface PostHogEventLike {
properties?: Record<string, unknown>
$set?: Record<string, unknown>
$set_once?: Record<string, unknown>
}
export function createPostHogBeforeSend() {
return function beforeSend<E extends PostHogEventLike>(
event: E | null
): E | null {
if (!event) return null
stripPiiKeys(event.properties)
stripPiiKeys(event.$set)
stripPiiKeys(event.$set_once)
return event
}
}

2016
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,15 +9,16 @@ publicHoistPattern:
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/check': ^0.9.8
'@astrojs/sitemap': ^3.7.1
'@astrojs/vue': ^5.0.0
'@astrojs/check': ^0.9.9
'@astrojs/sitemap': ^3.7.3
'@astrojs/vue': ^6.0.1
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.3
'@iconify/tools': ^5.0.3
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
'@lobehub/i18n-cli': ^1.26.1
@@ -65,7 +66,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
astro: ^6.4.2
axios: ^1.15.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -44,11 +44,14 @@
/>
</div>
<div
v-if="canFitToViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
>
<div class="flex flex-col rounded-lg bg-backdrop/30">
<div
v-if="canFitToViewer || canCenterCameraOnModel"
class="flex flex-col rounded-lg bg-backdrop/30"
>
<Button
v-if="canFitToViewer"
v-tooltip.left="{
value: $t('load3d.fitToViewer'),
showDelay: 300
@@ -61,25 +64,29 @@
>
<i class="pi pi-window-maximize text-lg text-base-foreground" />
</Button>
<Button
v-if="canCenterCameraOnModel"
v-tooltip.left="{
value: $t('load3d.centerCameraOnModel'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.centerCameraOnModel')"
@click="handleCenterCameraOnModel"
>
<i class="pi pi-compass text-lg text-base-foreground" />
</Button>
</div>
</div>
<div
v-if="enable3DViewer && node"
class="pointer-events-auto absolute top-24 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
</div>
<ViewerControls
v-if="enable3DViewer && node"
:node="node as LGraphNode"
/>
<div
v-if="!isPreview"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-24': !enable3DViewer,
'top-36': enable3DViewer
}"
>
<RecordingControls
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -142,6 +149,7 @@ const {
isRecording,
isPreview,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -175,6 +183,7 @@ const {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)

View File

@@ -81,6 +81,7 @@ export const useAuthActions = () => {
}
await authStore.logout()
useTelemetry()?.trackLogout()
toastStore.add({
severity: 'success',
summary: t('auth.signOut.success'),

View File

@@ -9,6 +9,10 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
export function modeIsAppMode(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
const enableAppBuilder = ref(true)
export function useAppMode() {
@@ -29,9 +33,7 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isAppMode = computed(() => modeIsAppMode(mode.value))
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -132,6 +132,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const canFitToViewer = ref(true)
const canCenterCameraOnModel = ref(false)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
const canExport = ref(true)
@@ -853,6 +854,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
canUseGizmo.value = caps?.gizmoTransform ?? true
@@ -971,6 +973,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
syncSceneModels()
}
const handleCenterCameraOnModel = () => {
load3d?.centerCameraOnModel()
}
const handleResetGizmoTransform = () => {
if (load3d) {
load3d.resetGizmoTransform()
@@ -1011,6 +1017,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isSplatModel,
isPlyModel,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,
canUseLighting,
canExport,
@@ -1046,6 +1053,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleSetGizmoMode,
handleResetGizmoTransform,
handleFitToViewer,
handleCenterCameraOnModel,
cleanup
}
}

View File

@@ -4,6 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -368,7 +369,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = node.type === 'Preview3D'
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -164,6 +164,7 @@ function makeLoad3DNode(
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
size: [300, 600],
setSize: vi.fn(),
addWidget: vi.fn(),
widgets: overrides.widgets ?? [
{ name: 'model_file', value: '' },
{ name: 'width', value: 512 },

View File

@@ -219,13 +219,15 @@ useExtensionService().registerExtension({
},
{
id: 'Comfy.Load3D.PLYEngine',
category: ['3D', 'PLY', 'PLY Engine'],
name: 'PLY Engine',
category: ['3D', 'PointCloud', 'Point Cloud Engine'],
name: 'Point Cloud Engine',
tooltip:
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
'Select the engine for loading point cloud PLY files. "threejs" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). "fastply" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.',
type: 'combo',
options: ['threejs', 'fastply', 'sparkjs'],
options: ['threejs', 'fastply'],
defaultValue: 'threejs',
migrateDeprecatedValue: (value) =>
value === 'sparkjs' ? 'threejs' : value,
experimental: true
}
],
@@ -267,40 +269,44 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
if (node.constructor.comfyClass === 'Load3D') {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
node.properties['Resource Folder'] = ''
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
})
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_file'
)
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
})
}
const widget = new ComponentWidgetImpl({
node: node,

View File

@@ -161,11 +161,23 @@ class Load3d {
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
}
private async repaintWhenSparkPaintable(): Promise<void> {
const sortComplete = this.sceneManager.awaitNextSparkDirty()
this.forceRender()
await sortComplete
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
@@ -626,7 +638,7 @@ class Load3d {
}
getCurrentModelCapabilities(): ModelAdapterCapabilities {
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
return this.adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES
}
clearModel(): void {
@@ -924,6 +936,22 @@ class Load3d {
this.forceRender()
}
public centerCameraOnModel(): void {
const bounds = this.modelManager.getCurrentBounds()
if (!bounds || bounds.isEmpty()) return
const center = bounds.getCenter(new THREE.Vector3())
const camera = this.cameraManager.activeCamera
const controls = this.controlsManager.controls
const offset = center.clone().sub(camera.position)
camera.position.add(offset)
controls.target.add(offset)
camera.updateMatrixWorld(true)
controls.update()
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()

View File

@@ -7,7 +7,11 @@ import type {
ModelManagerInterface
} from './interfaces'
import { LoaderManager } from './LoaderManager'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
function makeEventManagerStub() {
return {
@@ -28,6 +32,12 @@ type ModelManagerStub = {
originalURL: string | null
}
const STUB_CAPS = {} as ModelAdapterCapabilities
const loadResult = (object: THREE.Object3D) => ({
object,
capabilities: STUB_CAPS
})
function makeModelManagerStub(): ModelManagerStub {
return {
clearModel: vi.fn(),
@@ -41,14 +51,21 @@ function makeModelManagerStub(): ModelManagerStub {
}
}
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
getPLYEngineMock: vi.fn<() => string>(),
addAlert: vi.fn()
}))
const {
meshLoad,
splatLoad,
pointCloudLoad,
fetchModelDataMock,
isGaussianSplatPLYMock,
addAlert
} = vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
fetchModelDataMock: vi.fn<() => Promise<ArrayBuffer>>(),
isGaussianSplatPLYMock: vi.fn<(b: ArrayBuffer) => Promise<boolean>>(),
addAlert: vi.fn()
}))
vi.mock('./MeshModelAdapter', () => ({
MeshModelAdapter: class {
@@ -65,19 +82,35 @@ vi.mock('./PointCloudModelAdapter', () => ({
readonly extensions = ['ply'] as const
readonly capabilities = {}
load = pointCloudLoad
},
getPLYEngine: () => getPLYEngineMock()
}
}))
vi.mock('./SplatModelAdapter', () => ({
SplatModelAdapter: class {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
readonly capabilities = {}
matches = async (
ext: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean> => {
if (ext !== 'ply') return true
return isGaussianSplatPLYMock(await fetchBytes())
}
load = splatLoad
}
}))
vi.mock('./ModelAdapter', async () => {
const actual =
await vi.importActual<typeof import('./ModelAdapter')>('./ModelAdapter')
return { ...actual, fetchModelData: fetchModelDataMock }
})
vi.mock('@/scripts/metadata/ply', () => ({
isGaussianSplatPLY: isGaussianSplatPLYMock
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
@@ -87,7 +120,10 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
}))
type LoaderManagerInternals = {
pickAdapter(extension: string): ModelAdapter | null
pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null>
}
function makeLoaderManager() {
@@ -98,21 +134,21 @@ function makeLoaderManager() {
eventManager
)
const internals = lm as unknown as LoaderManagerInternals
return {
lm,
modelManager,
eventManager,
pick: internals.pickAdapter.bind(lm)
}
const pick = (ext: string) =>
internals.pickAdapter.call(lm, ext, () =>
fetchModelDataMock()
) as Promise<ModelAdapter | null>
return { lm, modelManager, eventManager, pick }
}
describe('LoaderManager', () => {
beforeEach(() => {
vi.clearAllMocks()
getPLYEngineMock.mockReturnValue('three')
meshLoad.mockResolvedValue(null)
splatLoad.mockResolvedValue(null)
pointCloudLoad.mockResolvedValue(null)
fetchModelDataMock.mockResolvedValue(new ArrayBuffer(0))
isGaussianSplatPLYMock.mockResolvedValue(false)
})
describe('getCurrentAdapter', () => {
@@ -123,7 +159,7 @@ describe('LoaderManager', () => {
it('exposes the picked adapter after a successful load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
@@ -132,7 +168,7 @@ describe('LoaderManager', () => {
it('resets to null at the start of a new load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -144,7 +180,7 @@ describe('LoaderManager', () => {
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
@@ -195,7 +231,10 @@ describe('LoaderManager', () => {
}
let adapterDuringClear: ModelAdapter | null | undefined
const adapterRef = { current: oldAdapter as ModelAdapter | null }
const adapterRef = {
current: oldAdapter as ModelAdapter | null,
capabilities: oldAdapter.capabilities as ModelAdapterCapabilities | null
}
const lm = new LoaderManager(
modelManager,
eventManager,
@@ -223,8 +262,8 @@ describe('LoaderManager', () => {
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
resolveSplatLoad = resolve
})
splatLoad.mockReturnValueOnce(slowSplatLoad)
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
splatLoad.mockReturnValueOnce(slowSplatLoad.then(loadResult))
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
const aPromise = lm.loadModel('api/view?filename=a.splat')
@@ -243,42 +282,36 @@ describe('LoaderManager', () => {
describe('pickAdapter', () => {
it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
(ext) => {
async (ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('mesh')
expect((await pick(ext))?.kind).toBe('mesh')
}
)
it.for(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
(ext) => {
async (ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('splat')
expect((await pick(ext))?.kind).toBe('splat')
}
)
it('routes .ply to the point-cloud adapter for the default three engine', () => {
getPLYEngineMock.mockReturnValue('three')
it('routes .ply to the splat adapter when the bytes look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
expect((await pick('ply'))?.kind).toBe('splat')
})
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
getPLYEngineMock.mockReturnValue('fastply')
it('falls back to the point-cloud adapter for .ply that is not 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
expect((await pick('ply'))?.kind).toBe('pointCloud')
})
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
getPLYEngineMock.mockReturnValue('sparkjs')
it('returns null for unknown extensions', async () => {
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('splat')
})
it('returns null for unknown extensions', () => {
const { pick } = makeLoaderManager()
expect(pick('xyz')).toBeNull()
expect(pick('')).toBeNull()
expect(await pick('xyz')).toBeNull()
expect(await pick('')).toBeNull()
})
})
@@ -348,7 +381,7 @@ describe('LoaderManager', () => {
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
meshLoad.mockResolvedValueOnce(loaded)
meshLoad.mockResolvedValueOnce(loadResult(loaded))
await lm.loadModel('api/view?filename=cube.glb')
@@ -366,7 +399,7 @@ describe('LoaderManager', () => {
it('emits modelLoadingEnd when the load completes', async () => {
const { lm, eventManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
@@ -378,7 +411,7 @@ describe('LoaderManager', () => {
it('forwards a decoded path and filename to the adapter', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel(
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
@@ -390,32 +423,105 @@ describe('LoaderManager', () => {
registerOriginalMaterial: expect.any(Function)
}),
'api/view?type=output&subfolder=nested%2Fdir&filename=',
'cube.glb'
'cube.glb',
expect.any(Function)
)
})
it('defaults the path to type=input when no type param is given', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=cube.glb')
expect(meshLoad).toHaveBeenCalledWith(
expect.anything(),
'api/view?type=input&subfolder=&filename=',
'cube.glb'
'cube.glb',
expect.any(Function)
)
})
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
getPLYEngineMock.mockReturnValue('sparkjs')
it('routes .ply to the point-cloud adapter when the header does not look like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(false)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('reroutes .ply through the splat adapter when the header looks like 3DGS', async () => {
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(splatLoad).toHaveBeenCalled()
expect(pointCloudLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('splat')
})
it('shares a single fetch between matches() and load() so .ply is not re-downloaded', async () => {
const buf = new ArrayBuffer(16)
fetchModelDataMock.mockResolvedValueOnce(buf)
isGaussianSplatPLYMock.mockResolvedValue(true)
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
// Adapter receives a fetchBytes function (memoized), not bytes directly.
expect(splatLoad).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
'scan.ply',
expect.any(Function)
)
// matches() called fetchBytes once; load()'s call hit the cached promise.
expect(fetchModelDataMock).toHaveBeenCalledTimes(1)
})
it('dispatches .ply via the adapter matches() tiebreaker, not extension order — a splat adapter whose matches() returns false yields to point-cloud', async () => {
const modelManager =
makeModelManagerStub() as unknown as ConstructorParameters<
typeof LoaderManager
>[0]
const eventManager = makeEventManagerStub()
// A splat adapter that ALSO claims '.ply' and is listed first.
// Without matches(), it would short-circuit. With matches() returning
// false (not a 3DGS PLY), the dispatcher must skip to the next
// candidate (point cloud).
const splatAdapter = {
kind: 'splat' as const,
extensions: ['ply', 'spz', 'splat', 'ksplat'] as const,
capabilities: {} as never,
matches: async (ext: string, fetchBytes: () => Promise<ArrayBuffer>) =>
ext === 'ply' ? isGaussianSplatPLYMock(await fetchBytes()) : true,
load: splatLoad
}
const pointCloudAdapter = {
kind: 'pointCloud' as const,
extensions: ['ply'] as const,
capabilities: {} as never,
load: pointCloudLoad
}
const lm = new LoaderManager(modelManager, eventManager, [
splatAdapter,
pointCloudAdapter
])
isGaussianSplatPLYMock.mockResolvedValue(false)
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
await lm.loadModel('api/view?filename=scan.ply')
expect(pointCloudLoad).toHaveBeenCalled()
expect(splatLoad).not.toHaveBeenCalled()
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
})
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
@@ -498,8 +604,8 @@ describe('LoaderManager', () => {
secondModel.name = 'second'
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(secondModel)
.mockImplementationOnce(() => firstLoad.then(loadResult))
.mockResolvedValueOnce(loadResult(secondModel))
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')

View File

@@ -4,9 +4,14 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { MeshModelAdapter } from './MeshModelAdapter'
import { createAdapterRef } from './ModelAdapter'
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { createAdapterRef, fetchModelData } from './ModelAdapter'
import type {
AdapterRef,
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import type {
EventManagerInterface,
@@ -36,14 +41,16 @@ function isNotFoundError(error: unknown): boolean {
}
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
* Default adapter set: mesh + splat + pointCloud. Each adapter declares the
* file extensions it owns. For shared extensions (.ply), the adapter with an
* async `matches()` tiebreaker is tried first; the unconditional adapter acts
* as the fallback — so SplatModelAdapter precedes PointCloudModelAdapter.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new PointCloudModelAdapter(),
new SplatModelAdapter()
new SplatModelAdapter(),
new PointCloudModelAdapter()
]
}
@@ -86,6 +93,7 @@ export class LoaderManager implements LoaderManagerInterface {
this.modelManager.clearModel()
this.adapterRef.current = null
this.adapterRef.capabilities = null
this.modelManager.originalURL = url
@@ -122,7 +130,8 @@ export class LoaderManager implements LoaderManagerInterface {
// can't clobber adapterRef.current that a newer load already
// wrote (or cleared).
this.adapterRef.current = result.adapter
await this.modelManager.setupModel(result.model)
this.adapterRef.capabilities = result.capabilities
await this.modelManager.setupModel(result.object)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
@@ -137,19 +146,18 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.extensions.includes(extension)
private async pickAdapter(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<ModelAdapter | null> {
const candidates = this.adapters.filter((a) =>
a.extensions.includes(extension)
)
if (!match) return null
// PLY may be routed through the splat adapter when the PLYEngine setting
// is sparkjs. Only honor the routing when both adapters are registered.
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
if (splat) return splat
for (const adapter of candidates) {
if (!adapter.matches) return adapter
if (await adapter.matches(extension, fetchBytes)) return adapter
}
return match
return null
}
private createLoadContext(): ModelLoadContext {
@@ -170,7 +178,11 @@ export class LoaderManager implements LoaderManagerInterface {
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
): Promise<{
object: THREE.Object3D
adapter: ModelAdapter
capabilities: ModelAdapterCapabilities
} | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
@@ -188,10 +200,24 @@ export class LoaderManager implements LoaderManagerInterface {
encodeURIComponent(subfolder) +
'&filename='
const adapter = this.pickAdapter(fileExtension)
let bytesPromise: Promise<ArrayBuffer> | null = null
const fetchBytes = () => (bytesPromise ??= fetchModelData(path, filename))
const adapter = await this.pickAdapter(fileExtension, fetchBytes)
if (!adapter) return null
const model = await adapter.load(this.createLoadContext(), path, filename)
return model ? { model, adapter } : null
const loadResult = await adapter.load(
this.createLoadContext(),
path,
filename,
fetchBytes
)
return loadResult
? {
object: loadResult.object,
capabilities: loadResult.capabilities,
adapter
}
: null
}
}

View File

@@ -160,8 +160,8 @@ describe('MeshModelAdapter', () => {
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(result!.object.children[0]).toBeInstanceOf(THREE.Mesh)
})
})
@@ -179,7 +179,7 @@ describe('MeshModelAdapter', () => {
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(fbxModel)
expect(result!.object).toBe(fbxModel)
})
it('disables frustum culling on SkinnedMesh children', async () => {
@@ -224,7 +224,7 @@ describe('MeshModelAdapter', () => {
'cube.obj'
)
expect(result).toBeInstanceOf(THREE.Group)
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
})
@@ -271,7 +271,7 @@ describe('MeshModelAdapter', () => {
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
expect(computeNormals).toHaveBeenCalled()
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(scene)
expect(result!.object).toBe(scene)
})
it('also handles .gltf filenames', async () => {

View File

@@ -11,7 +11,8 @@ import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
ModelLoadContext,
ModelLoadResult
} from './ModelAdapter'
export class MeshModelAdapter implements ModelAdapter {
@@ -45,20 +46,18 @@ export class MeshModelAdapter implements ModelAdapter {
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
): Promise<ModelLoadResult | null> {
const extension = filename.split('.').pop()?.toLowerCase()
switch (extension) {
case 'stl':
return this.loadSTL(ctx, path, filename)
case 'fbx':
return this.loadFBX(ctx, path, filename)
case 'obj':
return this.loadOBJ(ctx, path, filename)
case 'gltf':
case 'glb':
return this.loadGLTF(ctx, path, filename)
}
return null
const object = await (extension === 'stl'
? this.loadSTL(ctx, path, filename)
: extension === 'fbx'
? this.loadFBX(ctx, path, filename)
: extension === 'obj'
? this.loadOBJ(ctx, path, filename)
: extension === 'gltf' || extension === 'glb'
? this.loadGLTF(ctx, path, filename)
: Promise.resolve(null))
return object ? { object, capabilities: this.capabilities } : null
}
private async loadSTL(

View File

@@ -65,24 +65,59 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
}
/**
* Mutable handle to the currently active ModelAdapter. A single ref is
* created in `createLoad3d` and shared between LoaderManager (writer) and
* SceneModelManager + Load3d (readers), so capability/bounds/dispose lookups
* don't depend on construction order between those collaborators.
* Result returned by `ModelAdapter.load()`. Capabilities ride with the model
* because some adapters (notably PLY) produce different capability sets
* depending on the file contents — face-less point clouds expose only the
* 'pointCloud' material mode, indexed meshes expose the full set. Keeping
* capabilities per-load (not per-adapter) prevents stale state on the
* adapter instance between two successive loads.
*/
export type AdapterRef = { current: ModelAdapter | null }
export type ModelLoadResult = {
object: THREE.Object3D
capabilities: ModelAdapterCapabilities
}
export const createAdapterRef = (): AdapterRef => ({ current: null })
/**
* Mutable handle to the currently active ModelAdapter plus the capabilities
* reported by its most recent load. A single ref is created in `createLoad3d`
* and shared between LoaderManager (writer) and SceneModelManager + Load3d
* (readers), so capability/bounds/dispose lookups don't depend on
* construction order between those collaborators.
*/
export type AdapterRef = {
current: ModelAdapter | null
capabilities: ModelAdapterCapabilities | null
}
export const createAdapterRef = (): AdapterRef => ({
current: null,
capabilities: null
})
export interface ModelAdapter {
readonly kind: ModelAdapterKind
readonly extensions: readonly string[]
/**
* Default capabilities for this adapter family. `load()` may return a
* narrowed set for a specific model — read `adapterRef.capabilities` for
* the live per-model value rather than this.
*/
readonly capabilities: ModelAdapterCapabilities
/**
* Async tiebreaker when multiple adapters claim the same extension
* (e.g. .ply is shared by Gaussian splats and classic point clouds).
* Adapters that uniquely own their extensions can omit this.
*/
matches?(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean>
load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null>
filename: string,
fetchBytes?: () => Promise<ArrayBuffer>
): Promise<ModelLoadResult | null>
/**
* Optional. Return a world-space AABB for the given model. Adapters for
* renderers whose geometry is not walked by `Box3.setFromObject` (e.g.

View File

@@ -15,31 +15,40 @@ vi.mock('@/scripts/metadata/ply', () => ({
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
}))
const plyLoaderParse = vi.fn(() => makePLYGeometry({ withFaces: true }))
const fastPlyLoaderParse = vi.fn(() => makePLYGeometry({ withFaces: true }))
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
PLYLoader: class {
setPath = vi.fn()
parse = vi.fn(() => makePLYGeometry(false))
parse = plyLoaderParse
}
}))
vi.mock('./loader/FastPLYLoader', () => ({
FastPLYLoader: class {
parse = vi.fn(() => makePLYGeometry(false))
parse = fastPlyLoaderParse
}
}))
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
function makePLYGeometry(opts: {
withColors?: boolean
withFaces?: boolean
}): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
)
if (withColors) {
if (opts.withColors) {
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
}
if (opts.withFaces) {
geometry.setIndex([0, 1, 2])
}
return geometry
}
@@ -96,8 +105,8 @@ describe('PointCloudModelAdapter', () => {
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(result!.object).toBeInstanceOf(THREE.Group)
const child = result!.object.children[0]
expect(child).toBeInstanceOf(THREE.Mesh)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
})
@@ -108,9 +117,57 @@ describe('PointCloudModelAdapter', () => {
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(result!.object).toBeInstanceOf(THREE.Group)
const child = result!.object.children[0]
expect(child).toBeInstanceOf(THREE.Points)
})
it('forces Points rendering for a face-less PLY even on materialMode=original', async () => {
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('original')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
const child = result!.object.children[0]
expect(child).toBeInstanceOf(THREE.Points)
})
it('returns narrowed materialModes capability for a face-less PLY', async () => {
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
const adapter = new PointCloudModelAdapter()
const result = await adapter.load(
makeContext('original'),
'/api/view?',
'cloud.ply'
)
expect([...result!.capabilities.materialModes]).toEqual(['pointCloud'])
})
it('returns full materialModes capability for a face-bearing PLY (independent of prior loads)', async () => {
const adapter = new PointCloudModelAdapter()
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
const faceless = await adapter.load(
makeContext('original'),
'/api/view?',
'cloud.ply'
)
expect([...faceless!.capabilities.materialModes]).toEqual(['pointCloud'])
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: true }))
const faceful = await adapter.load(
makeContext('original'),
'/api/view?',
'mesh.ply'
)
expect([...faceful!.capabilities.materialModes]).toEqual([
'original',
'pointCloud',
'normal',
'wireframe'
])
})
})
})

View File

@@ -8,27 +8,30 @@ import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
ModelLoadContext,
ModelLoadResult
} from './ModelAdapter'
import type { MaterialMode } from './interfaces'
import { FastPLYLoader } from './loader/FastPLYLoader'
export function getPLYEngine(): string {
function getPLYEngine(): string {
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
}
const POINT_CLOUD_CAPABILITIES: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
exportable: true,
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
fitTargetSize: 5
}
export class PointCloudModelAdapter implements ModelAdapter {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
exportable: true,
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
fitTargetSize: 5
}
readonly capabilities = POINT_CLOUD_CAPABILITIES
private readonly plyLoader = new PLYLoader()
private readonly fastPlyLoader = new FastPLYLoader()
@@ -36,9 +39,10 @@ export class PointCloudModelAdapter implements ModelAdapter {
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const arrayBuffer = await fetchModelData(path, filename)
filename: string,
fetchBytes?: () => Promise<ArrayBuffer>
): Promise<ModelLoadResult | null> {
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
const isASCII = isPLYAsciiFormat(arrayBuffer)
const plyGeometry =
@@ -50,12 +54,18 @@ export class PointCloudModelAdapter implements ModelAdapter {
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
const hasFaces = (plyGeometry.index?.count ?? 0) > 0
if (ctx.materialMode === 'pointCloud') {
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
}
const object =
ctx.materialMode === 'pointCloud' || !hasFaces
? buildPointsGroup(ctx, plyGeometry, hasVertexColors)
: buildMeshGroup(ctx, plyGeometry, hasVertexColors)
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
const capabilities = hasFaces
? POINT_CLOUD_CAPABILITIES
: { ...POINT_CLOUD_CAPABILITIES, materialModes: ['pointCloud'] as const }
return { object, capabilities }
}
}

View File

@@ -14,6 +14,17 @@ export class SceneManager implements SceneManagerInterface {
gridHelper: THREE.GridHelper
private sparkRenderer: SparkRenderer
private nextSparkDirtyPromise: Promise<void> | null = null
private nextSparkDirtyResolve: (() => void) | null = null
awaitNextSparkDirty(): Promise<void> {
if (this.nextSparkDirtyPromise) return this.nextSparkDirtyPromise
this.nextSparkDirtyPromise = new Promise<void>((resolve) => {
this.nextSparkDirtyResolve = resolve
})
return this.nextSparkDirtyPromise
}
backgroundScene!: THREE.Scene
backgroundCamera: THREE.OrthographicCamera
backgroundMesh: THREE.Mesh | null = null
@@ -45,9 +56,23 @@ export class SceneManager implements SceneManagerInterface {
this.getActiveCamera = getActiveCamera
// Spark 2.x requires a SparkRenderer in the scene tree to render SplatMesh
// (Gaussian splat) instances; without it splats are silent no-ops. Kept
// alive across model reloads by SceneModelManager.clearModel.
this.sparkRenderer = new SparkRenderer({ renderer })
// instances; without it splats are silent no-ops.
//
// onDirty fires twice per splat first-paint cycle: once from updateInternal
// (data uploaded) and again from driveSort (sort completed; line 1105 in
// SparkRenderer.ts). We expose it as a passive promise — awaiters get
// notified, but the callback itself does NOT trigger a render. Wiring
// forceRender directly into onDirty caused a per-frame render-setDirty
// cascade that made splats visibly "balloon" during camera interaction.
this.sparkRenderer = new SparkRenderer({
renderer,
onDirty: () => {
const resolve = this.nextSparkDirtyResolve
this.nextSparkDirtyResolve = null
this.nextSparkDirtyPromise = null
resolve?.()
}
})
this.scene.add(this.sparkRenderer)
this.gridHelper = new THREE.GridHelper(20, 20)

View File

@@ -435,6 +435,11 @@ export class SceneModelManager implements ModelManagerInterface {
)
}
getCurrentBounds(): THREE.Box3 | null {
if (!this.currentModel) return null
return this.computeWorldBounds(this.currentModel)
}
async setupModel(model: THREE.Object3D): Promise<void> {
this.currentModel = model
model.name = 'MainModel'
@@ -456,6 +461,12 @@ export class SceneModelManager implements ModelManagerInterface {
this.setMaterialMode(pendingMaterialMode)
}
const validModes = this.getCurrentCapabilities().materialModes
if (validModes.length > 0 && !validModes.includes(this.materialMode)) {
this.materialMode = validModes[0]
this.eventManager.emitEvent('materialModeChange', this.materialMode)
}
if (this.currentUpDirection !== 'original') {
this.setUpDirection(this.currentUpDirection)
}

View File

@@ -6,7 +6,7 @@ import type { ModelLoadContext } from './ModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
const splatMeshSpies = {
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>(),
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer; fileName?: string }) => void>(),
dispose: vi.fn(),
getBoundingBox: vi.fn(
() =>
@@ -23,7 +23,7 @@ vi.mock('@sparkjsdev/spark', async () => {
dispose = splatMeshSpies.dispose
getBoundingBox = splatMeshSpies.getBoundingBox
constructor(opts: { fileBytes: ArrayBuffer }) {
constructor(opts: { fileBytes: ArrayBuffer; fileName?: string }) {
super()
splatMeshSpies.ctor(opts)
}
@@ -69,7 +69,7 @@ describe('SplatModelAdapter', () => {
it('handles the Gaussian splat extensions', () => {
const adapter = new SplatModelAdapter()
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat', 'ply'])
})
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
@@ -85,12 +85,18 @@ describe('SplatModelAdapter', () => {
'/api/view?',
'scene.splat'
)
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({ fileBytes: buf })
expect(result).toBeInstanceOf(THREE.Group)
expect(result.children).toHaveLength(1)
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({
fileBytes: buf,
fileName: 'scene.splat'
})
expect(result!.object).toBeInstanceOf(THREE.Group)
expect(result!.object.children).toHaveLength(1)
expect(result!.capabilities.lighting).toBe(false)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
expect(ctx.setOriginalModel).toHaveBeenCalledWith(
result!.object.children[0]
)
})
it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => {
@@ -100,7 +106,7 @@ describe('SplatModelAdapter', () => {
'scene.splat'
)
const splat = result.children[0]
const splat = result!.object.children[0]
expect(splat.quaternion.x).toBe(1)
expect(splat.quaternion.y).toBe(0)
expect(splat.quaternion.z).toBe(0)
@@ -121,11 +127,12 @@ describe('SplatModelAdapter', () => {
describe('computeBounds', () => {
it('returns the SplatMesh bounding box transformed to world space', async () => {
const adapter = new SplatModelAdapter()
const group = await adapter.load(
const result = await adapter.load(
makeContext(),
'/api/view?',
'scene.splat'
)
const group = result!.object
const splat = group.children[0]
splat.position.set(10, 0, 0)
@@ -152,13 +159,13 @@ describe('SplatModelAdapter', () => {
describe('disposeModel', () => {
it('calls dispose on every SplatMesh in the model tree', async () => {
const adapter = new SplatModelAdapter()
const group = await adapter.load(
const result = await adapter.load(
makeContext(),
'/api/view?',
'scene.splat'
)
adapter.disposeModel(group)
adapter.disposeModel(result!.object)
expect(splatMeshSpies.dispose).toHaveBeenCalledOnce()
})

View File

@@ -1,16 +1,19 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { isGaussianSplatPLY } from '@/scripts/metadata/ply'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
ModelLoadContext,
ModelLoadResult
} from './ModelAdapter'
export class SplatModelAdapter implements ModelAdapter {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
@@ -21,21 +24,33 @@ export class SplatModelAdapter implements ModelAdapter {
fitTargetSize: 20
}
async matches(
extension: string,
fetchBytes: () => Promise<ArrayBuffer>
): Promise<boolean> {
if (extension !== 'ply') return true
return isGaussianSplatPLY(await fetchBytes())
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await fetchModelData(path, filename)
filename: string,
fetchBytes?: () => Promise<ArrayBuffer>
): Promise<ModelLoadResult> {
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
const splatMesh = new SplatMesh({
fileBytes: arrayBuffer,
fileName: filename
})
await splatMesh.initialized
splatMesh.quaternion.set(1, 0, 0, 0)
ctx.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
return { object: splatGroup, capabilities: this.capabilities }
}
computeBounds(model: THREE.Object3D): THREE.Box3 | null {

View File

@@ -125,7 +125,11 @@ vi.mock('./Load3d', () => ({
}
}))
type FakeLoaderManager = { adapterRefArg: { current: ModelAdapter | null } }
type FakeAdapterRef = {
current: ModelAdapter | null
capabilities: ModelAdapterCapabilities | null
}
type FakeLoaderManager = { adapterRefArg: FakeAdapterRef }
type FakeSceneModelManager = {
getCurrentCapabilities: () => unknown
getBoundsFromAdapter: (model: unknown) => unknown
@@ -134,7 +138,7 @@ type FakeSceneModelManager = {
}
type FakeLoad3d = {
deps: {
adapterRef: { current: ModelAdapter | null }
adapterRef: FakeAdapterRef
loaderManager: FakeLoaderManager
modelManager: FakeSceneModelManager
}
@@ -222,6 +226,7 @@ describe('createLoad3d', () => {
function withAdapter(adapter: ModelAdapter) {
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
instance.deps.adapterRef.current = adapter
instance.deps.adapterRef.capabilities = adapter.capabilities
return instance
}

View File

@@ -84,7 +84,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
getActiveCamera,
(size, center) => cameraManager.setupForModel(size, center),
(model) => gizmoManager.setupForModel(model),
() => adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
() => adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
(model) => adapterRef.current?.computeBounds?.(model) ?? null,
(model) => adapterRef.current?.disposeModel?.(model),
() => adapterRef.current?.defaultCameraPose?.() ?? null

View File

@@ -0,0 +1,18 @@
/**
* Canonical lists of node types backed by the Load3D viewer infrastructure.
* Adding a new node type that uses the viewer = one line change here.
*/
const LOAD3D_PREVIEW_NODES = new Set([
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)
export const isLoad3dNode = (nodeType: string): boolean =>
LOAD3D_ALL_NODES.has(nodeType)

View File

@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))
type Hook = (
@@ -83,7 +84,13 @@ describe('load3dLazy', () => {
expect(enabledExtensionsGetter).not.toHaveBeenCalled()
})
it.for(['Load3D', 'Preview3D', 'SaveGLB'])(
it.for([
'Load3D',
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud',
'SaveGLB'
])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {
const { hook } = await loadLazyExtensionFresh()

View File

@@ -15,7 +15,7 @@ import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const LOAD3D_NODE_TYPES = new Set(['Load3D', 'Preview3D', 'SaveGLB'])
import { isLoad3dNode } from './load3d/nodeTypes'
let load3dExtensionsLoaded = false
let load3dExtensionsLoading: Promise<ComfyExtension[]> | null = null
@@ -34,8 +34,12 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
load3dExtensionsLoading = (async () => {
const before = new Set(useExtensionStore().enabledExtensions)
// Import both extensions - they will self-register via useExtensionService()
await Promise.all([import('./load3d'), import('./saveMesh')])
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
load3dExtensionsLoaded = true
return useExtensionStore().enabledExtensions.filter(
(ext) => !before.has(ext)
@@ -45,13 +49,6 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
return load3dExtensionsLoading
}
/**
* Check if a node type is a 3D node that requires THREE.js
*/
function isLoad3dNodeType(nodeTypeName: string): boolean {
return LOAD3D_NODE_TYPES.has(nodeTypeName)
}
// Register a lightweight extension that triggers lazy loading
useExtensionService().registerExtension({
name: 'Comfy.Load3DLazy',
@@ -60,7 +57,7 @@ useExtensionService().registerExtension({
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef
) {
if (isLoad3dNodeType(nodeData.name)) {
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.
if (nodeData.name === 'Load3D') {

View File

@@ -0,0 +1,372 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyExtension } from '@/types/comfy'
const {
registerExtensionMock,
waitForLoad3dMock,
onLoad3dReadyMock,
configureForSaveMeshMock,
getLoad3dMock,
toastAddAlertMock,
getNodeByLocatorIdMock,
nodeToLoad3dMapMock
} = vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
waitForLoad3dMock: vi.fn(),
onLoad3dReadyMock: vi.fn(),
configureForSaveMeshMock: vi.fn(),
getLoad3dMock: vi.fn(),
toastAddAlertMock: vi.fn(),
getNodeByLocatorIdMock: vi.fn(),
nodeToLoad3dMapMock: new Map()
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({ registerExtension: registerExtensionMock })
}))
vi.mock('@/services/load3dService', () => ({
useLoad3dService: () => ({ getLoad3d: getLoad3dMock })
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap: nodeToLoad3dMapMock
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configureForSaveMesh = configureForSaveMeshMock
}
}))
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: {} }
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: getNodeByLocatorIdMock
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: toastAddAlertMock })
}))
type ExtCreated = ComfyExtension & {
nodeCreated: (node: LGraphNode) => Promise<void>
getNodeMenuItems: (node: LGraphNode) => unknown[]
onNodeOutputsUpdated: (
nodeOutputs: Record<string, Record<string, unknown>>
) => void
}
async function loadExtensionsFresh(): Promise<{
splatExt: ExtCreated
pointCloudExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3dPreviewExtensions')
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
return {
splatExt: splatCall[0] as ExtCreated,
pointCloudExt: pointCloudCall[0] as ExtCreated
}
}
interface FakeLoad3d {
whenLoadIdle: () => Promise<void>
isSplatModel: ReturnType<typeof vi.fn>
forceRender: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
setTargetSize: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
getModelInfo: ReturnType<typeof vi.fn>
cameraManager: { perspectiveCamera: { fov: number } }
currentLoadGeneration: number
}
function makeLoad3dMock(): FakeLoad3d {
return {
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn(() => false),
forceRender: vi.fn(),
setCameraState: vi.fn(),
setTargetSize: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective'),
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
getModelInfo: vi.fn(() => null),
cameraManager: { perspectiveCamera: { fov: 75 } },
currentLoadGeneration: 0
}
}
interface FakeWidget {
name: string
value: unknown
}
function makePreviewNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
widgets: FakeWidget[]
}> = {}
): LGraphNode {
return {
constructor: {
comfyClass: overrides.comfyClass ?? 'PreviewGaussianSplat'
},
size: [400, 550],
setSize: vi.fn(),
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
function setupBaseMocks() {
vi.clearAllMocks()
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
cb(makeLoad3dMock())
})
onLoad3dReadyMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
cb(makeLoad3dMock())
})
}
describe('load3dPreviewExtensions module registration', () => {
beforeEach(setupBaseMocks)
it('registers both preview extensions on import', async () => {
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
})
})
describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not PreviewGaussianSplat', async () => {
const { splatExt } = await loadExtensionsFresh()
const node = makePreviewNode({ comfyClass: 'OtherNode' })
await splatExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('triggers a model load against the output folder on execute', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode()
await splatExt.nodeCreated(node)
node.onExecuted!({ result: ['scene.ply'] })
expect(node.properties['Last Time Model File']).toBe('scene.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'scene.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('persists backend-provided camera_info into node.properties so onLoad3dReady can restore it after remount', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode()
const cameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1
}
await splatExt.nodeCreated(node)
node.onExecuted!({ result: ['scene.ply', cameraState] })
const cameraConfig = node.properties['Camera Config'] as
| { state?: typeof cameraState }
| undefined
expect(cameraConfig?.state).toEqual(cameraState)
})
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const widthWidget: FakeWidget & { callback?: (v: number) => void } = {
name: 'width',
value: 800
}
const heightWidget: FakeWidget & { callback?: (v: number) => void } = {
name: 'height',
value: 600
}
const node = makePreviewNode({
widgets: [
{ name: 'model_file', value: '' },
{ name: 'image', value: '' },
widthWidget,
heightWidget
]
})
await splatExt.nodeCreated(node)
expect(load3d.setTargetSize).toHaveBeenCalledWith(800, 600)
expect(typeof widthWidget.callback).toBe('function')
expect(typeof heightWidget.callback).toBe('function')
widthWidget.callback!(1024)
expect(load3d.setTargetSize).toHaveBeenLastCalledWith(1024, 600)
})
it("installs a sceneWidget.serializeValue that returns the viewer's current camera_info + model_3d_info", async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
const cameraState = { position: { x: 1, y: 2, z: 3 } }
load3d.getCameraState = vi.fn(() => cameraState)
load3d.getModelInfo = vi.fn(() => ({
position: { x: 0, y: 0, z: 0 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 }
}))
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const sceneWidget: FakeWidget & {
serializeValue?: () => Promise<unknown>
} = { name: 'image', value: '' }
const node = makePreviewNode({
widgets: [{ name: 'model_file', value: '' }, sceneWidget]
})
nodeToLoad3dMapMock.set(node, load3d)
await splatExt.nodeCreated(node)
expect(typeof sceneWidget.serializeValue).toBe('function')
const payload = (await sceneWidget.serializeValue!()) as {
camera_info: unknown
model_3d_info: unknown[]
}
expect(payload.camera_info).toEqual(cameraState)
expect(payload.model_3d_info).toHaveLength(1)
})
it('shows an error toast when onExecuted has no file path', async () => {
const { splatExt } = await loadExtensionsFresh()
const node = makePreviewNode()
await splatExt.nodeCreated(node)
node.onExecuted!({ result: [] })
expect(toastAddAlertMock).toHaveBeenCalledWith(
'toastMessages.unableToGetModelFilePath'
)
})
})
describe('Comfy.PreviewPointCloud.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not PreviewPointCloud', async () => {
const { pointCloudExt } = await loadExtensionsFresh()
const node = makePreviewNode({ comfyClass: 'OtherNode' })
await pointCloudExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('triggers a model load against the output folder on execute', async () => {
const { pointCloudExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode({ comfyClass: 'PreviewPointCloud' })
await pointCloudExt.nodeCreated(node)
node.onExecuted!({ result: ['pointcloud.ply'] })
expect(node.properties['Last Time Model File']).toBe('pointcloud.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'pointcloud.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
})
describe('Comfy.PreviewGaussianSplat.onNodeOutputsUpdated', () => {
beforeEach(setupBaseMocks)
it('skips entries whose comfyClass is not PreviewGaussianSplat', async () => {
const { splatExt } = await loadExtensionsFresh()
getNodeByLocatorIdMock.mockReturnValue(makePreviewNode({ comfyClass: 'X' }))
splatExt.onNodeOutputsUpdated({
'node:1': { result: ['scene.ply'] }
})
expect(waitForLoad3dMock).not.toHaveBeenCalled()
})
it('skips entries with no result file path', async () => {
const { splatExt } = await loadExtensionsFresh()
getNodeByLocatorIdMock.mockReturnValue(makePreviewNode())
splatExt.onNodeOutputsUpdated({ 'node:1': { result: [] } })
expect(waitForLoad3dMock).not.toHaveBeenCalled()
})
})
describe('Comfy.PreviewGaussianSplat.getNodeMenuItems', () => {
beforeEach(setupBaseMocks)
it('returns [] for non-PreviewGaussianSplat nodes', async () => {
const { splatExt } = await loadExtensionsFresh()
const items = splatExt.getNodeMenuItems(
makePreviewNode({ comfyClass: 'OtherNode' })
)
expect(items).toEqual([])
})
it('returns [] for splat models', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.isSplatModel = vi.fn(() => true)
getLoad3dMock.mockReturnValue(load3d)
const items = splatExt.getNodeMenuItems(makePreviewNode())
expect(items).toEqual([])
})
})

View File

@@ -0,0 +1,212 @@
import { nextTick } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { NodeExecutionOutput, NodeOutputWith } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import type { ComfyExtension } from '@/types/comfy'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
type PreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, Model3DInfo?]
}>
function applyResultToLoad3d(
node: LGraphNode,
load3d: Load3d,
filePath: string,
cameraState: CameraState | undefined
): void {
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
if (cameraState) {
const existing = node.properties['Camera Config'] as
| CameraConfig
| undefined
node.properties['Camera Config'] = {
cameraType: load3d.getCurrentCameraType(),
fov: 75,
...existing,
state: cameraState
}
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('output', normalizedPath, {
silentOnNotFound: true
})
const targetGeneration = load3d.currentLoadGeneration
void load3d.whenLoadIdle().then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
load3d.forceRender()
})
}
function createPreview3DExtension(
comfyClass: string,
extensionName: string
): ComfyExtension {
const applyPreviewOutput = (
node: LGraphNode,
result: NonNullable<PreviewOutput['result']>
): void => {
const filePath = result[0]
const cameraState = result[1]
if (!filePath) return
useLoad3d(node).waitForLoad3d((load3d) => {
applyResultToLoad3d(node, load3d, filePath, cameraState)
})
}
return {
name: extensionName,
onNodeOutputsUpdated(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
) {
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
const result = (output as PreviewOutput).result
if (!result?.[0]) continue
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node || node.constructor.comfyClass !== comfyClass) continue
applyPreviewOutput(node, result)
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== comfyClass) return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== comfyClass) return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('output', lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const targetGeneration = load3d.currentLoadGeneration
void load3d.whenLoadIdle().then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
load3d.forceRender()
})
})
waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
load3d.setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
load3d.setTargetSize(widthWidget.value as number, value)
}
}
if (sceneWidget) {
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
}
node.onExecuted = function (output: PreviewOutput) {
onExecuted?.call(this, output)
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
applyResultToLoad3d(node, load3d, filePath, result?.[1])
}
})
}
}
}
useExtensionService().registerExtension(
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
)
useExtensionService().registerExtension(
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
)

View File

@@ -199,7 +199,6 @@
"name": "محرك PLY",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "اختر المحرك لتحميل ملفات PLY. \"threejs\" يستخدم محمل Three.js PLY الأصلي (الأفضل لملفات الشبكة). \"fastply\" يستخدم محملًا محسنًا لملفات PLY السحابية النقطية بنسق ASCII. \"sparkjs\" يستخدم Spark.js لملفات PLY الخاصة بتقنية Gaussian Splatting ثلاثية الأبعاد."

View File

@@ -1971,6 +1971,7 @@
"materialMode": "Material Mode",
"showSkeleton": "Show Skeleton",
"fitToViewer": "Fit to Viewer",
"centerCameraOnModel": "Center Camera on Model",
"scene": "Scene",
"model": "Model",
"camera": "Camera",

View File

@@ -196,12 +196,11 @@
"tooltip": "Sets the minimum allowable light intensity value for 3D scenes. This defines the lower brightness limit that can be set when adjusting lighting in any 3D widget."
},
"Comfy_Load3D_PLYEngine": {
"name": "PLY Engine",
"tooltip": "Select the engine for loading PLY files. \"threejs\" uses the native Three.js PLYLoader (best for mesh PLY files). \"fastply\" uses an optimized loader for ASCII point cloud PLY files. \"sparkjs\" uses Spark.js for 3D Gaussian Splatting PLY files.",
"name": "Point Cloud Engine",
"tooltip": "Select the engine for loading point cloud PLY files. \"threejs\" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). \"fastply\" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.",
"options": {
"threejs": "threejs",
"fastply": "fastply",
"sparkjs": "sparkjs"
"fastply": "fastply"
}
},
"Comfy_Load3D_ShowGrid": {

View File

@@ -199,7 +199,6 @@
"name": "Motor PLY",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "Selecciona el motor para cargar archivos PLY. \"threejs\" utiliza el cargador nativo Three.js PLYLoader (mejor para archivos PLY de malla). \"fastply\" utiliza un cargador optimizado para archivos PLY de nube de puntos ASCII. \"sparkjs\" utiliza Spark.js para archivos PLY de Gaussian Splatting 3D."

View File

@@ -199,7 +199,6 @@
"name": "موتور PLY",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "موتور بارگذاری فایل‌های PLY را انتخاب کنید. «threejs» از PLYLoader بومی Three.js استفاده می‌کند (مناسب برای فایل‌های مش PLY). «fastply» از یک بارگذار بهینه‌شده برای فایل‌های point cloud PLY به صورت ASCII استفاده می‌کند. «sparkjs» از Spark.js برای فایل‌های 3D Gaussian Splatting PLY استفاده می‌کند."

View File

@@ -199,7 +199,6 @@
"name": "Moteur PLY",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "Sélectionnez le moteur pour charger les fichiers PLY. « threejs » utilise le PLYLoader natif de Three.js (idéal pour les fichiers PLY de maillage). « fastply » utilise un chargeur optimisé pour les fichiers PLY de nuages de points ASCII. « sparkjs » utilise Spark.js pour les fichiers PLY de Gaussian Splatting 3D."

View File

@@ -199,7 +199,6 @@
"name": "PLYエンジン",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "PLYファイルを読み込むエンジンを選択します。「threejs」はネイティブのThree.js PLYLoaderを使用メッシュPLYファイルに最適。「fastply」はASCIIポイントクラウドPLYファイル用の最適化ローダーを使用。「sparkjs」は3DガウシアンスプラッティングPLYファイル用にSpark.jsを使用します。"

View File

@@ -199,7 +199,6 @@
"name": "PLY 엔진",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "PLY 파일을 불러올 엔진을 선택합니다. \"threejs\"는 네이티브 Three.js PLYLoader를 사용하며(메시 PLY 파일에 적합), \"fastply\"는 ASCII 포인트 클라우드 PLY 파일에 최적화된 로더를 사용합니다. \"sparkjs\"는 3D Gaussian Splatting PLY 파일에 Spark.js를 사용합니다."

View File

@@ -199,7 +199,6 @@
"name": "Engine PLY",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "Selecione a engine para carregar arquivos PLY. \"threejs\" usa o PLYLoader nativo do Three.js (melhor para arquivos PLY de malha). \"fastply\" usa um carregador otimizado para arquivos PLY de nuvem de pontos ASCII. \"sparkjs\" usa Spark.js para arquivos PLY de Gaussian Splatting 3D."

View File

@@ -199,7 +199,6 @@
"name": "Движок PLY",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "Выберите движок для загрузки PLY файлов. \"threejs\" использует встроенный загрузчик Three.js PLYLoader (лучше всего подходит для файлов сетки PLY). \"fastply\" использует оптимизированный загрузчик для ASCII PLY файлов облака точек. \"sparkjs\" использует Spark.js для PLY файлов с 3D Gaussian Splatting."

View File

@@ -199,7 +199,6 @@
"name": "PLY Motoru",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "PLY dosyalarını yüklemek için motoru seçin. \"threejs\" yerel Three.js PLYLoader'ı kullanır (ağ PLY dosyaları için en iyisi). \"fastply\" ASCII nokta bulutu PLY dosyaları için optimize edilmiş bir yükleyici kullanır. \"sparkjs\" ise 3D Gaussian Splatting PLY dosyaları için Spark.js kullanır."

View File

@@ -199,7 +199,6 @@
"name": "PLY 引擎",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "選擇用於載入 PLY 檔案的引擎。「threejs」使用原生 Three.js PLYLoader適合網格 PLY 檔案。「fastply」使用優化的 ASCII 點雲 PLY 檔案載入器。「sparkjs」使用 Spark.js 處理 3D Gaussian Splatting PLY 檔案。"

View File

@@ -2009,6 +2009,7 @@
"exportRecording": "导出录制",
"exportingModel": "正在导出模型...",
"fitToViewer": "适应视图",
"centerCameraOnModel": "将相机居中到模型",
"fov": "视场",
"gizmo": {
"label": "控件",

View File

@@ -196,13 +196,12 @@
"tooltip": "设置3D场景允许的最小光照强度值。此值定义在任何3D组件中调整光照时可设置的最小亮度。"
},
"Comfy_Load3D_PLYEngine": {
"name": "PLY引擎",
"name": "点云引擎",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "选择用于加载PLY文件的引擎。\"threejs\" 使用原生Three.js PLYLoader适合网格PLY文件)。\"fastply\" 用于优化的ASCII点云PLY文件加载。\"sparkjs\" 用于3D高斯斑点PLY文件的Spark.js。"
"tooltip": "选择用于加载点云PLY文件的引擎。\"threejs\" 使用原生Three.js PLYLoader支持二进制和ASCII可处理网格)。\"fastply\" 使用针对ASCII PLY的优化解析器。3D高斯泼溅PLY会自动识别并始终通过sparkjs渲染,不受此设置影响。"
},
"Comfy_Load3D_ShowGrid": {
"name": "初始网格可见性",

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { oauthConsentRedirect } from '@/platform/cloud/onboarding/onboardingCloudRoutes'
import {
captureOAuthRequestId,
clearOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
useSessionCookie: () => ({ createSessionOrThrow })
}))
describe('oauthConsentRedirect', () => {
beforeEach(() => {
clearOAuthRequestId()
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
})
it('routes to user-check and mints no session when no OAuth flow is pending', async () => {
const target = await oauthConsentRedirect()
expect(target).toEqual({ name: 'cloud-user-check' })
expect(createSessionOrThrow).not.toHaveBeenCalled()
})
it('mints the Cloud session cookie before redirecting to consent when resuming OAuth', async () => {
// Regression: an already-signed-in user (Firebase) carries no Cloud session
// cookie, so the consent challenge fetch fails unless the cookie is minted
// here, mirroring the post-login resume path.
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
const target = await oauthConsentRedirect()
expect(createSessionOrThrow).toHaveBeenCalledOnce()
expect(target).toEqual({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
})
it('still lands on consent when session minting fails so the view can surface the error', async () => {
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const target = await oauthConsentRedirect()
expect(target).toEqual({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
expect(warn).toHaveBeenCalledWith(
'Failed to establish Cloud session cookie before OAuth consent:',
expect.any(Error)
)
} finally {
warn.mockRestore()
}
})
})

View File

@@ -5,14 +5,39 @@ import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
// `oauth_request_id` capture lives in the global router.beforeEach guard
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
// guards read it back via getOAuthRequestId().
function oauthConsentRedirect() {
//
// When an already-signed-in user is bounced to login/signup mid-OAuth, we skip
// the sign-in step and jump straight to consent. The consent challenge fetch
// (GET /oauth/authorize) is authenticated by the Cloud *session cookie*, which
// is a separate credential from the Firebase client login that `isLoggedIn`
// reflects. The post-login resume path mints that cookie via
// `createSessionOrThrow` (see useOAuthPostLoginRedirect); the already-signed-in
// path must do the same. Without it the consent fetch is unauthenticated, the
// backend 302s it to login, and the consent view fails with
// "OAuth request failed. Please restart from the client app."
export async function oauthConsentRedirect() {
const oauthRequestId = getOAuthRequestId()
return oauthRequestId
? {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
: { name: 'cloud-user-check' }
if (!oauthRequestId) return { name: 'cloud-user-check' }
try {
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await useSessionCookie().createSessionOrThrow()
} catch (error) {
// Best effort: if the cookie can't be minted (e.g. an expired Firebase
// token), still land on the consent view so it can surface the failure and
// prompt the user to restart from the client app, rather than silently
// dropping the OAuth flow.
console.warn(
'Failed to establish Cloud session cookie before OAuth consent:',
error
)
}
return {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
}
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
@@ -34,7 +59,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(oauthConsentRedirect())
return next(await oauthConsentRedirect())
}
}
next()
@@ -52,7 +77,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(oauthConsentRedirect())
return next(await oauthConsentRedirect())
}
}
next()

View File

@@ -70,6 +70,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackUserLoggedIn?.())
}
trackLogout(): void {
this.dispatch((provider) => provider.trackLogout?.())
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata

View File

@@ -7,6 +7,7 @@ const hoisted = vi.hoisted(() => {
const mockInit = vi.fn()
const mockIdentify = vi.fn()
const mockPeopleSet = vi.fn()
const mockReset = vi.fn()
const mockOnUserResolved = vi.fn()
return {
@@ -14,13 +15,15 @@ const hoisted = vi.hoisted(() => {
mockInit,
mockIdentify,
mockPeopleSet,
mockReset,
mockOnUserResolved,
mockPosthog: {
default: {
init: mockInit,
capture: mockCapture,
identify: mockIdentify,
people: { set: mockPeopleSet }
people: { set: mockPeopleSet },
reset: mockReset
}
}
}
@@ -56,6 +59,29 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
})
}))
const mockAppMode = vi.hoisted(() => ({
mode: { value: 'app' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => mockAppMode
}))
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
getExecutionContext: () => ({
is_template: false,
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: []
})
}))
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
function createProvider(
@@ -72,6 +98,7 @@ describe('PostHogTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRemoteConfig.value = null
mockAppMode.isAppMode.value = false
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token'
} as typeof window.__CONFIG__
@@ -236,6 +263,42 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('logout', () => {
it('calls posthog.reset(true) when logout fires after init', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackLogout()
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
})
it('defers reset to init when logout fires before init resolves', async () => {
const provider = createProvider()
provider.trackLogout()
expect(hoisted.mockReset).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
})
it('drops pre-logout queued events when logout fires before init', async () => {
const provider = createProvider()
provider.trackUserLoggedIn()
provider.trackLogout()
await vi.dynamicImportSettled()
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
TelemetryEvents.USER_LOGGED_IN,
expect.anything()
)
})
})
describe('page view', () => {
it('captures page view with page_name property', async () => {
const provider = createProvider()
@@ -263,4 +326,91 @@ describe('PostHogTelemetryProvider', () => {
)
})
})
describe('execution tracking', () => {
it('stamps is_app_mode and view_mode on the execution_start event', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
mockAppMode.isAppMode.value = true
mockAppMode.mode.value = 'app'
provider.trackWorkflowExecution()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.EXECUTION_START,
expect.objectContaining({
is_app_mode: true,
view_mode: 'app',
trigger_source: 'unknown'
})
)
})
})
describe('before_send', () => {
it('strips PII keys from event properties, $set, and $set_once', async () => {
createProvider()
await vi.dynamicImportSettled()
const { before_send } = hoisted.mockInit.mock.calls[0][1]
const event = {
event: 'test',
properties: {
email: 'props@example.com',
prompt: 'hello',
user_email: 'props_user@example.com',
$email: 'props_posthog@example.com',
method: 'google'
},
$set: {
email: 'set@example.com',
user_email: 'set_user@example.com',
$email: 'set_posthog@example.com',
name: 'keep me'
},
$set_once: {
email: 'set_once@example.com',
plan: 'free'
}
}
const result = before_send(event)
// event.properties — all four PII keys stripped, non-PII preserved
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
// event.$set — PII stripped, non-PII preserved
// posthog.identify(id, { email }) lands here, not in properties
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
// event.$set_once — PII stripped, non-PII preserved
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
const remoteBefore_send = vi.fn()
mockRemoteConfig.value = {
posthog_config: {
before_send: remoteBefore_send,
person_profiles: 'always'
}
}
createProvider()
await vi.dynamicImportSettled()
const initConfig = hoisted.mockInit.mock.calls[0][1]
expect(initConfig.before_send).not.toBe(remoteBefore_send)
expect(initConfig.person_profiles).toBe('identified_only')
})
})
})

View File

@@ -1,6 +1,8 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -83,6 +85,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private posthog: PostHog | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private shouldResetOnInit = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
@@ -114,9 +117,20 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig
...serverConfig,
person_profiles: 'identified_only',
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
// automatically when persistence includes 'cookie' (the default).
// Explicit override interacts badly with posthog-js#3578 where reset() fails
// to clear localStorage on other subdomains, causing identity bleed on logout.
before_send: createPostHogBeforeSend()
})
this.isInitialized = true
if (this.shouldResetOnInit) {
this.posthog!.reset(true)
this.eventQueue = []
this.shouldResetOnInit = false
}
this.flushEventQueue()
useCurrentUser().onUserResolved((user) => {
@@ -240,6 +254,15 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackLogout(): void {
if (!this.posthog) {
this.shouldResetOnInit = true
this.eventQueue = []
return
}
this.posthog.reset(true)
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
@@ -425,9 +448,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
trackWorkflowExecution(): void {
const context = getExecutionContext()
const { isAppMode, mode } = useAppMode()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
trigger_source: this.lastTriggerSource ?? 'unknown',
is_app_mode: isAppMode.value,
view_mode: mode.value
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined

View File

@@ -97,6 +97,8 @@ export interface ExecutionContext {
toolkit_node_names: string[]
toolkit_node_count: number
trigger_source?: ExecutionTriggerSource
is_app_mode?: boolean
view_mode?: string
}
/**
@@ -107,6 +109,9 @@ export interface ExecutionErrorMetadata {
nodeId?: string
nodeType?: string
error?: string
is_app_mode?: boolean
workflow_id?: string
view_mode?: string
}
/**
@@ -114,6 +119,9 @@ export interface ExecutionErrorMetadata {
*/
export interface ExecutionSuccessMetadata {
jobId: string
is_app_mode?: boolean
workflow_id?: string
view_mode?: string
}
/**
@@ -151,15 +159,19 @@ export interface WorkflowImportMetadata {
| 'template'
| 'shared_url'
| 'unknown'
/** Whether the imported/opened workflow is an app (extra.linearMode). */
is_app?: boolean
}
export interface EnterLinearMetadata {
source?: string
workflow_id?: string
}
export interface WorkflowSavedMetadata {
is_app: boolean
is_new: boolean
workflow_id?: string
}
export interface DefaultViewSetMetadata {
@@ -175,6 +187,8 @@ type ShareFlowStep =
export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
is_app?: boolean
workflow_id?: string
}
/**
@@ -384,6 +398,7 @@ export interface TelemetryProvider {
trackSignupOpened?(): void
trackAuth?(metadata: AuthMetadata): void
trackUserLoggedIn?(): void
trackLogout?(): void
// Subscription flow events
trackSubscription?(

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { workflowTelemetryId } from './workflowTelemetryId'
function fakeWorkflow(
activeId: string | null,
initialId: string | null
): ComfyWorkflow {
return {
activeState: activeId === null ? null : { id: activeId },
initialState: initialId === null ? null : { id: initialId }
} as unknown as ComfyWorkflow
}
describe('workflowTelemetryId', () => {
it('prefers the active state id', () => {
expect(workflowTelemetryId(fakeWorkflow('active-1', 'initial-1'))).toBe(
'active-1'
)
})
it('falls back to the initial state id when the active state has no id', () => {
expect(workflowTelemetryId(fakeWorkflow(null, 'initial-1'))).toBe(
'initial-1'
)
})
it('returns undefined when no id is available', () => {
expect(workflowTelemetryId(fakeWorkflow(null, null))).toBeUndefined()
expect(workflowTelemetryId(null)).toBeUndefined()
expect(workflowTelemetryId(undefined)).toBeUndefined()
})
})

View File

@@ -0,0 +1,12 @@
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
/**
* Stable id used to attribute telemetry to a workflow/app. Mirrors the id
* execution events report (active state, falling back to initial state) so
* created and opened events can be joined to runs of the same workflow.
*/
export function workflowTelemetryId(
workflow: ComfyWorkflow | null | undefined
): string | undefined {
return workflow?.activeState?.id ?? workflow?.initialState?.id
}

View File

@@ -16,6 +16,7 @@ import {
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useTelemetry } from '@/platform/telemetry'
import { workflowTelemetryId } from '@/platform/telemetry/utils/workflowTelemetryId'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
// eslint-disable-next-line import-x/no-restricted-paths
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
@@ -178,6 +179,7 @@ export const useWorkflowService = () => {
}
}
let savedWorkflow = workflow
if (isSelfOverwrite) {
workflow.changeTracker?.prepareForSave()
// Call workflowStore.saveWorkflow directly: saveWorkflowAs emits its own is_new:true event below, so delegating to saveWorkflow() would also fire is_new:false and run prepareForSave a second time.
@@ -199,9 +201,14 @@ export const useWorkflowService = () => {
}
target.changeTracker?.prepareForSave()
await workflowStore.saveWorkflow(target)
savedWorkflow = target
}
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
useTelemetry()?.trackWorkflowSaved({
is_app: isApp,
is_new: true,
workflow_id: workflowTelemetryId(savedWorkflow)
})
return true
}
@@ -240,7 +247,11 @@ export const useWorkflowService = () => {
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
useTelemetry()?.trackWorkflowSaved({
is_app: isApp,
is_new: false,
workflow_id: workflowTelemetryId(workflow)
})
return true
}
@@ -458,7 +469,10 @@ export const useWorkflowService = () => {
function trackIfEnteringApp(workflow: ComfyWorkflow) {
if (!wasAppMode && workflow.initialMode === 'app') {
useTelemetry()?.trackEnterLinear({ source: 'workflow' })
useTelemetry()?.trackEnterLinear({
source: 'workflow',
workflow_id: workflowTelemetryId(workflow)
})
}
}

View File

@@ -69,7 +69,9 @@
<template v-else-if="sharedWorkflow">
<main :class="cn('flex gap-8 px-8 pt-4 pb-6', !hasAssets && 'flex-col')">
<div class="flex min-w-0 flex-1 flex-col gap-12 py-4">
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
<h2
class="m-0 text-2xl font-semibold wrap-anywhere text-base-foreground"
>
{{ workflowName }}
</h2>
<p

View File

@@ -206,6 +206,7 @@ import { useWorkflowService } from '@/platform/workflow/core/services/workflowSe
import { useAppMode } from '@/composables/useAppMode'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import { workflowTelemetryId } from '@/platform/telemetry/utils/workflowTelemetryId'
import { appendJsonExt } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -437,7 +438,9 @@ const {
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
source: getShareSource()
source: getShareSource(),
is_app: workflow.initialMode === 'app',
workflow_id: workflowTelemetryId(workflow)
})
return result

View File

@@ -80,6 +80,10 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ activeWorkflow: null })
}))
describe('useTemplateUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -5,6 +5,8 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { workflowTelemetryId } from '@/platform/telemetry/utils/workflowTelemetryId'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -123,7 +125,10 @@ export function useTemplateUrlLoader() {
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
useTelemetry()?.trackEnterLinear({
source: 'template_url',
workflow_id: workflowTelemetryId(useWorkflowStore().activeWorkflow)
})
canvasStore.linearMode = true
}
} catch (error) {

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