Compare commits

...

58 Commits

Author SHA1 Message Date
snomiao
eba055befe fix(pr-badge): avoid i18n interpolation for backend URL to prevent HTML escaping
escapeParameter: true in i18n.ts caused slashes in URLs to render as
/ in the popover tooltip. Concatenate the label and URL directly
instead of passing the URL through t() interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 13:39:21 +09:00
snomiao
4c8e5ad797 refactor(connect): move API key input into connection status section
Contextually show the API key field only after backend is tested and
cloud API is detected, co-located with the cloud API info and the
"Generate an API key" link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:11:18 +09:00
snomiao
d3bd6f9f12 feat(connect): add API key test button and hide when --disable-api-nodes
- Add Test button for Comfy API Key input (validates against /customers endpoint)
- Show ✓/✗ status after test
- Update hint text: "Only needed for cloud-API nodes"
- Hide API key section when backend reports --disable-api-nodes in argv
- Show notice when API nodes are disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 07:40:09 +09:00
snomiao
a785f72aa0 feat(connect): add optional API key input to connection panel
Users can now enter their Comfy API key on /connect before clicking
"Connect & Open ComfyUI". The key is saved to localStorage (comfy_api_key)
so it's picked up automatically after connecting — no need to re-enter it
in Settings → API Key.

Existing key is pre-filled from localStorage if already set.
Empty field on connect leaves any existing stored key unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 06:43:05 +09:00
snomiao
6130880992 fix(ci): use prod cloud API config for preview builds
Preview builds were using stagingapi.comfy.org (USE_PROD_CONFIG=false
default), causing a constant cloud environment mismatch warning for
every user who followed the quick-start guide — their local ComfyUI
backend defaults to api.comfy.org (production).

Setting USE_PROD_CONFIG=true aligns the preview with the default backend
behavior so the mismatch warning only fires when the user deliberately
passes --comfy-api-base=staging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 06:38:17 +09:00
snomiao
fb46996002 fix(topbar-badge): remove redundant tooltip text from full-mode popover
The tooltip string (with HTML-escaped URLs and dot-separated fields) was
showing inside the popover alongside the cleaner popoverLinks section.
Remove it — the links already convey all the same info without the HTML
entity artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 05:15:43 +09:00
snomiao
bf11a90cd8 fix(topbar-badge): add popover to full display mode when popoverLinks set
Full mode only had v-tooltip (plain text on hover). When popoverLinks is
present, the badge now shows a clickable Popover with links, matching the
behavior of icon-only and compact modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 04:16:59 +09:00
snomiao
0c4861162d feat(pr-badge): show short commit hash in badge text alongside PR number
Badge now shows "PR #11118 · abc12345" so reviewers can see the exact
commit at a glance without opening the popover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 04:03:37 +09:00
snomiao
17f5bde180 feat(topbar-badge): add popoverLinks for clickable PR/commit/connect links
Extends TopbarBadge with a popoverLinks field that renders as <a> tags
in the popover. The PR preview badge now shows clickable links to the
GitHub PR page, author profile, commit, and a "Configure backend →"
link that re-opens /connect to change the backend URL at any time.

Also fixes prPreviewBadges backendUrl to read from localStorage directly
(shows '—' when no remote backend configured, instead of the CF Pages
domain itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 03:56:57 +09:00
snomiao
d840020427 fix: unexport DEFAULT_CLOUD_API_BASE (knip unused export)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
1a993fc1dc feat(topbar): add PR preview badge with build provenance
- Add prPreviewBadges extension (active only when CI_PR_NUMBER is set):
  - Topbar warning badge showing "PR #XXXX", tooltip with author/commit/backend URL
  - About panel badges: clickable PR#, author (@handle), commit hash links
- Declare __CI_PR_NUMBER__, __CI_PR_AUTHOR__, __CI_BRANCH__, __CI_RUN_ID__,
  __CI_JOB_ID__, __COMFYUI_FRONTEND_COMMIT__ as global TS constants in vite-env.d.ts
- Extract resolveBackendCloudBase() to shared util at
  src/platform/connectionPanel/resolveBackendCloudBase.ts
- Add comfy_api_base as optional field in zSystemStats schema (companion to
  backend PR Comfy-Org/ComfyUI#13571)
- Add prPreview i18n keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
321c32463e feat(connection-panel): consume comfy_api_base from system_stats directly
When the backend exposes `system.comfy_api_base` (added in companion
backend PR), use it directly instead of parsing it out of `argv`.
Falls back to argv parsing for older backends that don't yet include
the field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
GitHub Action
0a441ab896 [automated] Apply ESLint and Oxfmt fixes 2026-04-27 09:07:05 +09:00
snomiao
cf01213235 feat(connection-panel): add copy buttons to command blocks and expand local-network guidance
- Add CopyCodeBlock component with copy-to-clipboard and 2s "copied" checkmark feedback
- Replace all static <code> blocks in ConnectionPanelView with CopyCodeBlock
- Pre-compute launch command strings in script to avoid HTML entity escaping in template
- Expand localAccess section with --listen command for multi-device LAN testing
- Add corsOriginNote explaining why the exact origin matters (not *)
- Add i18n keys: corsOriginNote, localAccessListenDescription, localAccessListenNote

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
61bcf0a8bc feat(connection-panel): add preview-build warning aside with provenance
Adds a prominent amber aside under the panel title showing:
- This is an in-flight PR build, UI may change rapidly, not for production
- Build provenance: PR number, commit hash, author (all linked to GitHub)
- Trust warning: do not connect a backend you care about unless you trust
  the PR author, since a malicious frontend can read or modify any
  workflow / model / output on the connected backend.

Wires `CI_PR_AUTHOR` from `github.event.pull_request.user.login` through
the build env into a new `__CI_PR_AUTHOR__` define.
2026-04-27 09:07:05 +09:00
snomiao
0b90645d87 feat(connection-panel): link to API key page for the backend's cloud target
When the backend reports a known Comfy Cloud API base (prod or staging via
`--comfy-api-base`), surface a link to the matching platform's API-keys page
(platform.comfy.org or stagingplatform.comfy.org). Custom/unknown bases hide
the link rather than guess a URL.
2026-04-27 09:07:05 +09:00
snomiao
8f60294f63 feat(connection-panel): detect backend cloud API base from system_stats argv
Parse `--comfy-api-base` out of `/system_stats` `system.argv` and surface the
backend's Comfy Cloud target in the connection panel. When it disagrees with
the frontend's build-time `getComfyApiBaseUrl()`, show an amber warning so
users can spot the mismatch before sign-in tokens get rejected.

Records the cleaner long-term path (add `comfy_api_base` to the backend's
`/features` endpoint) in docs/backend-cloud-api-base-feature-flag.md.
2026-04-27 09:07:05 +09:00
snomiao
da8be4dc6c ci(preview): use sanitized branch name for cloudflare alias
Aligns frontend preview with storybook/playwright deploy scripts which
already key the cloudflare branch alias on the sanitized git branch name.
Falls back to pr-$PR_NUMBER when BRANCH_NAME is unset.
2026-04-27 09:07:05 +09:00
snomiao
3fa9c4522a refactor(connection-panel): combine comfy-cli install and comfy install into one step
uv pip install puts comfy on PATH immediately, so both commands
chain with && in the same shell. Collapses 4 quick-start steps to 3
and keeps the shell flow linear for copy-paste.
2026-04-27 09:07:04 +09:00
snomiao
2b675d6b5c fix: install pnpm in deploy-and-comment job so wrangler can run
The deploy script uses `pnpm dlx wrangler` when wrangler isn't on
PATH, but the deploy-and-comment job runs on a bare ubuntu image
with no pnpm — all three retry attempts fail with `pnpm: command
not found`. Set up pnpm + Node via the same actions setup-frontend
uses.
2026-04-27 09:07:04 +09:00
snomiao
66072fc4a6 fix: import cn from @comfyorg/tailwind-utils directly
The @/utils/tailwindUtil shim was removed from main in #11453; CI
merge-commit builds fail for this branch. Switch the import to the
package that replaced it. Both paths resolve locally today, so the
change is a drop-in.
2026-04-27 09:07:04 +09:00
snomiao
5f612e19b2 feat(connection-panel): promote ComfyUI-Manager in install guide
comfy install bundles ComfyUI-Manager by default. Surface that in
the quick start, plus an explanatory aside on why Manager smooths
the UX (one-click missing-model/custom-node install). For the
manual python alt path, add the git clone step explicitly.
2026-04-27 09:07:04 +09:00
snomiao
eb1fe9d88a fix: align backend probe base with ComfyApi for subpath deploys 2026-04-27 09:07:04 +09:00
snomiao
51e77c65ad chore: ignore runtime-generated scheduled_tasks.lock 2026-04-27 09:07:04 +09:00
snomiao
324d20477e refactor: drop unused branch_name parameter from preview deploy script 2026-04-27 09:07:04 +09:00
snomiao
6fb9915b45 chore: use pnpm dlx wrangler instead of npm/npx to match repo policy 2026-04-27 09:07:04 +09:00
snomiao
fed451edac fix: use BASE_URL for post-connect reload to support subpath deploys 2026-04-27 09:07:04 +09:00
snomiao
3ee55dfa1e fix: move page padding out of BaseViewTemplate into ConnectionPanelView 2026-04-27 09:07:04 +09:00
snomiao
93073cc242 fix: guard against malformed remote backend URL in localStorage 2026-04-27 09:06:08 +09:00
snomiao
5ef07f09fa chore: remove TODO.md planning file from repo 2026-04-27 09:06:07 +09:00
snomiao
2d5d77f7db fix: localize build metadata labels via vue-i18n
Move hardcoded English strings (PR #, Version:, Commit:, etc.)
in buildLabel and buildTooltip to i18n keys for consistency
with the rest of the connection panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
ef4d8622fa fix: scope CORS command to current preview origin
Replace wildcard * with window.location.origin so the
displayed command only opens the backend to this deployment,
not every origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
dc2d8375fd fix: announce connection status to assistive technology
Add role="status" and aria-live="polite" to the connection
status section so screen readers announce test results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
bd0a10d7f0 test: clean up stubGlobal state between tests
vi.restoreAllMocks() does not undo vi.stubGlobal(), so stubs
like fetch and WebSocket leaked between tests. Add afterEach
with vi.unstubAllGlobals() and re-stub localStorage in
beforeEach for isolation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
5b75bb5bbf fix: use PR-scoped preview alias instead of branch normalization
Branch normalization collapsed distinct names (feature/foo,
feature_foo, feature-foo) to the same key, letting unrelated PRs
overwrite each other's preview. pr-$PR_NUMBER is unique per PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
8bdc850830 fix: add contents:read permission and serialize deploy comment jobs
comment-on-pr-start needs contents:read for checkout, and
deploy-and-comment must wait for it to avoid overwriting
the initial "Building..." comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
95d0f22906 test: sync default.json fixture with defaultGraph models metadata
The locale-export test loads default.json then reloads the page
(which uses defaultGraph.ts). Since defaultGraph.ts now embeds
models metadata in the CheckpointLoader properties, the fixture
must match to avoid a mismatch between the English and Chinese
exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
a53ea4dae2 fix: use exit 1 instead of return at top-level in deploy script
return is invalid outside a function/sourced-script context and
would mask the original wrangler installation failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
7ba5fcdf1a fix: use URL API for WebSocket URL construction in testWs
Parse base URL with new URL() instead of regex stripping so
paths/query/fragments in user-entered URLs cannot corrupt the
WebSocket endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
c176723bbc test: cover testConnection failure, URL normalization, and Connect button reveal
Adds three ConnectionPanelView tests for the previously uncovered branches:
- HTTP fetch failure renders an error message
- URLs entered without a protocol get http:// prepended
- Connect & Open ComfyUI button appears after a successful HTTP+WS test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
559001341a test: extract isBackendReachable from router and cover its branches
Codecov reported 0% coverage on the backend-probe logic embedded in
router.ts beforeEnter. The router module is hard to test in isolation
because importing it constructs the router and pulls in cloud/desktop
side effects. Extract the probe to a pure async function in
platform/connectionPanel/ and unit-test it: success, non-2xx, missing
system field, fetch rejection, trailing-slash normalization, and
unconfigured (same-origin) fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
b9c677f54c fix: embed model download metadata in defaultGraph CheckpointLoader
The hardcoded default workflow used on first load referenced
v1-5-pruned-emaonly-fp16.safetensors but had an empty properties object,
so the missing-model panel had no URL to offer for download. The version
in the workflow_templates package already includes properties.models with
the HuggingFace download URL — mirror it here so the Download button
appears when the checkpoint isn't installed locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
6304a60656 fix: don't use SPA route pathname as router base
When user lands directly on a SPA route like /connect, the previous logic
set the router base to /connect, causing the /connect route to map to
/connect/connect (and similarly for any deep-linked SPA route). A deploy
directory pathname always ends with /; SPA route pathnames don't. Use the
trailing slash as the discriminator: keep pathname as base when it ends
with / (reverse-proxy subpath case), otherwise fall back to BASE_URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
0b0a1076f4 fix: use grid instead of flex m-auto for BaseViewTemplate centering
Flexbox has a known gotcha where auto margins go negative when content
exceeds the container, pushing content off the top. CSS grid's
place-items-center handles overflow correctly — content centers when it
fits, aligns to top when it doesn't, and scrolls properly. Fixes /connect
still being unscrollable from the previous m-auto attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
d1968f2033 fix: allow BaseViewTemplate content to scroll when taller than viewport
Using items-center on a flex container with overflow-auto cuts off the
top of content that exceeds the viewport height because flex centering
doesn't interact with scrolling. Replace with an m-auto inner wrapper,
which still centers short content but lets tall content flow naturally
and scroll. Fixes the long /connect panel being unscrollable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
f129dc2aeb feat: recommend uv for Python setup in connection panel
Users without Python installed were getting stuck on pip install comfy-cli.
Restructure the Quick Start to install uv first (which is cross-platform,
a single-line shell install, and doesn't require Python itself to be
present). Then use uv pip install comfy-cli --system. Keep the plain
pip path in the collapsible Alternative section with a note about the
Python 3.10+ requirement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
1a4b766fb7 ci: prefer branch alias URL over hash URL in preview comment
Wrangler outputs both a unique-per-deployment hash URL and a stable
branch alias URL. The previous regex picked the first match which was
the hash, changing every deploy. Use the branch alias when present so
the PR comment link stays stable and human-readable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
3e8022c0d1 test: migrate ConnectionPanelView to @testing-library/vue
@vue/test-utils was removed from main as part of the test framework
migration. Migrate this test file to @testing-library/vue with
userEvent and Testing Library queries (getByRole, getByDisplayValue,
getByText) to satisfy the testing-library/no-node-access rule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
cb696d8426 fix: separate origin from base path so remote backend WS URL is valid
Previously, when a remote backend URL was set via the connection panel,
api_base contained the full origin and path, causing the WebSocket URL
to be malformed: wss://127.0.0.1:8188http://127.0.0.1:8188/ws.

Now we keep api_host/api_base as just host/path and use a separate
remoteOrigin field that gets prepended in apiURL/fileURL/internalURL.
The WebSocket protocol is also now derived from the remote backend URL
when set, instead of always copying from the page protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
3720ba3829 feat: auto-redirect to /connect when backend unreachable, add comfy-cli guide
When deployed to static hosting (Cloudflare Pages), the frontend now
detects that no backend is available and redirects to /connect instead
of hanging on "Loading ComfyUI". The connection panel includes comfy-cli
quick start guide, connection tester, and "Connect & Go" button.
API requests are routed to the user-configured remote backend URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
cdc834705a feat: deploy frontend preview to Cloudflare Pages with ConnectionPanel
- Add CI workflow to build and deploy to comfy-ui.pages.dev on every push/PR
- Add deploy script with auto PR comments following storybook pattern
- Add ConnectionPanelView at /connect with backend URL config, HTTP/WS test, CLI guide, build info
- Inject CI metadata (branch, PR#, run ID, job ID) as build-time defines
- Add i18n strings, route, unit tests (8/8 passing)

Amp-Thread-ID: https://ampcode.com/threads/T-019d7738-d170-7409-8699-23a55d8ad5e7
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 09:06:06 +09:00
Dante
177224452e test(assets): add E2E tests for media type filter (#10784)
## Summary

Add Playwright E2E tests for media type filter in assets sidebar.

## Changes

- Add `filterButton`, `filterCheckbox(label)`, `openFilterMenu()` to
`AssetsSidebarTab` fixture
- New `Assets sidebar - media type filter` describe block with 3 tests:
- Filter menu shows all 4 media type checkboxes (Image, Video, Audio,
3D)
  - Unchecking image filter hides image assets
  - Re-enabling filter restores hidden assets
- Mock jobs use distinct file extensions (.png, .mp4, .mp3) since
filtering is extension-based

## Review Focus

Tests tagged `@cloud` — filter button is gated behind `isCloud` in
`MediaAssetFilterBar.vue`.

Fixes #10780

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10784-test-assets-add-E2E-tests-for-media-type-filter-3356d73d3650810a8ecdd102e9f5b47e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:15:03 +09:00
Terry Jia
6bf75b4cf0 refactor(load3d): introduce ModelAdapter abstraction for the loader switch (#11627)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
the per-format loader logic needs to live behind a stable seam before
splat-specific fixes (orientation, async-decoder waits, GPU dispose,
custom bounds) and capability-driven UX gating can be added without
touching `LoaderManager`'s switch every time.

## Summary

Pure refactor. Extracts the per-extension switch inside `LoaderManager`
into three `ModelAdapter` implementations and wires the manager to
dispatch through them. **No behavior change** — same loader code paths,
same outputs, same fallbacks. Sixth in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**:
- `ModelAdapter.ts` (new): defines the `ModelAdapter` interface (`kind`,
`extensions`, `capabilities`, `load`), a `ModelLoadContext` that exposes
only the `SceneModelManager` surface adapters need (`setOriginalModel`,
`registerOriginalMaterial`, `standardMaterial`, `materialMode`), and a
shared `fetchModelData(path, filename)` helper.
- `MeshModelAdapter.ts` (new): owns `stl`, `fbx`, `obj`, `gltf`, `glb`.
Each branch is a 1:1 lift of the corresponding `case` from
`LoaderManager.loadModelInternal` on `main`.
- `PointCloudModelAdapter.ts` (new): owns `ply`. Includes the existing
`FastPLYLoader` / `PLYLoader` fallback and the `pointCloud` vs mesh
branching logic.
- `SplatModelAdapter.ts` (new): owns `spz`, `splat`, `ksplat`. Wraps the
`SplatMesh` in a `Group` exactly like the previous `loadSplat` did.
- `LoaderManager.ts`: now owns just an adapter array (default = the
three above) and a small dispatch path. `pickAdapter` matches by
extension and routes PLY → splat when the `Comfy.Load3D.PLYEngine`
setting is `sparkjs` (preserving the previous routing).
`getCurrentAdapter()` is the new public reader used by `Load3d`.
- `Load3d.isSplatModel` / `isPlyModel` now query
`loaderManager.getCurrentAdapter()?.kind` instead of doing
tree-introspection (`containsSplatMesh`) or `instanceof
THREE.BufferGeometry` checks. Same return values, decoupled from the
model shape.
- `LoaderManagerInterface` no longer exposes the per-format loader
fields (`gltfLoader`, `objLoader`, etc.); those are now
adapter-internal.
- `SceneModelManager` is **unchanged** in this PR. Its existing
`containsSplatMesh()` traversal and PLY material-mode rebuild stay put;
a follow-up PR refactors them once capability gating is in place.

## Review Focus

- **Loader equivalence**: the body of every `case` in `main`'s
`LoaderManager.loadModelInternal` is now in the corresponding adapter's
`load()` method. Easiest way to verify: diff `main`'s
`LoaderManager.loadModelInternal` against the four `load()` bodies and
confirm each branch's behavior (file fetch + parse + material wiring +
group wrapping) is byte-identical.
- **Dispatch parity**: `pickAdapter` produces the same routing as `main`
— extension match first, then the PLYEngine === 'sparkjs' override
hoisted up from inside the old `loadPLY`.
- **Capability fields are dormant**: the `ModelAdapterCapabilities`
record (`fitToViewer`, `materialModes`, `fitTargetSize`, …) is declared
on every adapter but **not consumed anywhere in this PR**.
SceneModelManager / Load3d / Load3DControls still read no capability
data. The follow-up PR turns these on.
- **`setOriginalModel` / `registerOriginalMaterial`**: adapters now go
through the `ModelLoadContext` getter rather than reaching into
`modelManager` directly. The context's `standardMaterial` and
`materialMode` are exposed via getters so a late-bound `materialMode` is
read at the actual call site, not snapshotted at context creation.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** |
| `MeshModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (new) | 97.22% | 61.11% | 75% | 97.22% |
| `SplatModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.17% | 86.66% | **100%**
|
| `Load3d.ts` (modified) | 6.63% | 0% | 13.68% | 6.7% |

All four new files are at or near 100% via dedicated unit tests for each
adapter (load happy path, error propagation, extension declarations,
capability shape). `LoaderManager.test.ts` exercises the dispatch logic
— extension matching, the `ply → splat` sparkjs override, the stale-load
discard, the load-context proxying — across 34 cases. The two changed
`Load3d.ts` methods (`isSplatModel`, `isPlyModel`) get dedicated tests
verifying they read the current adapter's `kind` and fall back to
`false` when none is loaded.

`Load3d.ts`'s overall 6.7% number is the pre-existing baseline — the
existing `Load3d.test.ts` covers façade methods via prototype injection
rather than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). PR-F's surface in
`Load3d.ts` is two method bodies, both covered by the new adapter-driven
kind queries test.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11627-refactor-load3d-introduce-ModelAdapter-abstraction-for-the-loader-switch-34d6d73d3650811b8a1ccc55b45100f2)
by [Unito](https://www.unito.io)
2026-04-26 18:32:51 -04:00
Christian Byrne
7a13340989 chore: add 301 redirects for old Framer case study URLs (#11654)
## Summary

Add 301 redirects for old Framer case study URLs to new `/customers/`
pages.

## Changes

- Add `redirects` config to `apps/website/astro.config.ts` mapping two
old Framer enterprise case study URLs to their new Astro customer pages

## Testing

### Automated

- Website build succeeds with redirect pages generated
- Lint, typecheck, and format checks pass

### E2E Verification Steps

1. Deploy to preview
2. Visit
`/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping`
— should 301 redirect to `/customers/moment-factory/`
3. Visit
`/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui`
— should 301 redirect to `/customers/series-entertainment/`

Fixes #11583

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11654-chore-add-301-redirects-for-old-Framer-case-study-URLs-34e6d73d36508187a386eed3e25cf1b2)
by [Unito](https://www.unito.io)
2026-04-26 15:13:32 -07:00
Terry Jia
1b07e82ff7 fix: resolve mesh widget thumbnails via asset preview API (#11538)
## Summary
The Load3d select-model widget was passing the raw .glb URL as the item
preview_url, which browsers can't render as an image, producing the
broken-image icon on cloud/local asset-enabled servers.

Resolve thumbnails lazily from the asset API using the preview_id link
(matching Media3DTop's behavior), look up by basename to stay consistent
with the write path in useLoad3d, and fall back to a 3D-box placeholder
when no preview exists yet.

## Screenshots
before
<img width="1112" height="1333" alt="image"
src="https://github.com/user-attachments/assets/a8fa88ad-ab82-4951-be03-d28111322e30"
/>

after
with asset-enable on BE

https://github.com/user-attachments/assets/34b416af-5729-4ad0-bf17-722461ffc659

without asset-enable on BE
<img width="1026" height="1201" alt="image"
src="https://github.com/user-attachments/assets/71fd463f-ca77-4d63-85ed-01261d032d53"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11538-fix-resolve-mesh-widget-thumbnails-via-asset-preview-API-34a6d73d365081d2aefac044dab0dfc3)
by [Unito](https://www.unito.io)
2026-04-26 18:08:30 -04:00
Terry Jia
9f4c54eb24 refactor: extract Load3d right-click guard to load3dContextMenuGuard (#11625)
## Summary

Pull the right-click vs right-drag detection out of `Load3d` into a
sibling helper. Mechanical refactor — no behavior change. Third of four
small PRs splitting up the [`remove-ply-3dgs-nodes-squashed`
mega-commit.](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.)

## Changes

- **What**: New `load3dContextMenuGuard.ts` exports
`attachContextMenuGuard(target, onMenu, { isDisabled, dragThreshold })`.
It installs `mousedown` / `mousemove` / `contextmenu` listeners against
a single `AbortController` and returns one dispose function.
- `Load3d` now calls `attachContextMenuGuard(this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event), { isDisabled: () =>
this.isViewerMode })` and stores the returned disposer in a single
field. Drops four private fields (`rightMouseStart`, `rightMouseMoved`,
`dragThreshold`, `contextMenuAbortController`) plus the now-redundant
`showNodeContextMenu` private method.
- The 5px drag threshold and `isViewerMode` gating are preserved.

## Review Focus

- The three event handlers (`mousedown`, `mousemove`, `contextmenu`)
inside the new helper match the old inline implementations one-for-one —
same `e.button === 2` / `e.buttons === 2` checks, same call to
`exceedsClickThreshold`, same `preventDefault` + `stopPropagation`
ordering.
- `isDisabled: () => this.isViewerMode` replaces the inline `if
(this.isViewerMode) return` early-out — same gate, just lifted to a
callback.
- A single `AbortController.abort()` (in the returned disposer) replaces
the old four-field teardown in `Load3d.remove()`.
- 9 unit tests cover the helper: click vs drag distinction at the
threshold, drag-then-click reset, `isDisabled` short-circuit, and the
disposer detaching all three listeners.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `load3dContextMenuGuard.ts` (new) | **100%** | **93.33%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | 7.12% | 0% | 13.97% | 7.18% |

The single uncovered branch on `load3dContextMenuGuard.ts` (line 22) is
the default-parameter fallback for `dragThreshold` when the caller omits
it — `Load3d` always passes `{ isDisabled, dragThreshold: 5 }` through
`attachContextMenuGuard`'s second-arg destructure, so the default never
fires under the production call path. Adding a test that omits
`dragThreshold` would push it to 100%; left as-is to avoid a
change-detector test for a default value.

The `Load3d.ts` numbers are the pre-existing baseline on `main` —
`Load3d.test.ts` covers façade methods via prototype injection rather
than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). The
`initContextMenu` rewrite and the `remove()` teardown change both sit in
those same uninstantiated paths and rely on browser e2e for end-to-end
coverage, the same as before. Net: the click-vs-drag logic that
previously had no unit test is now ≥93% covered through the extracted
helper.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11625-refactor-extract-Load3d-right-click-guard-to-load3dContextMenuGuard-34d6d73d36508162aecef46553a3f50d)
by [Unito](https://www.unito.io)
2026-04-26 17:55:09 -04:00
Terry Jia
6f6fc88b0f test: add unit tests for MaskEditorContent main container (#11651)
## Summary

Add unit tests for `MaskEditorContent` (the mask editor's main
orchestration container), raising coverage from 0% to **94.11% / 83.72%
/ 83.33% / 94.11%** (statements / branches / functions / lines).

## Changes

- **What**: Add `src/components/maskeditor/MaskEditorContent.test.ts`
(12 tests) covering:
- **Mount**: keyboard listeners attached, ResizeObserver observes the
container, all 5 canvas refs assigned to the store before init runs.
- **Init flow**: `loader.loadFromNode` → `imageLoader.loadImages` →
`panZoom.initializeCanvasPanZoom` → `canvasHistory.saveInitialState` →
`brushDrawing.initGPUResources` → `initPreviewCanvas` chain runs in
order; child UI (`ToolPanel` / `PointerZone` / `SidePanel` /
`BrushCursor`) only renders after init succeeds; GPU preview canvas
resolution matches the mask canvas.
- **Init errors**: rejection from `loader.loadFromNode` or
`panZoom.initializeCanvasPanZoom` is caught, logged, and triggers
`dialogStore.closeDialog()`.
- **ResizeObserver**: callback invokes `panZoom.invalidatePanZoom()`
(captured the constructor argument to call it manually).
  - **Drag**: `Ctrl+drag` is preventDefault'd; plain drag is not.
- **Unmount**: cleanup runs `brushDrawing.saveBrushSettings`,
`keyboard.removeListeners`, `canvasHistory.clearStates`,
`store.resetState`, `dataStore.reset`.

## Review Focus

- Heavy mock surface (10 modules): the 3 stores, 5 composables, plus 4
child Vue components and `LoadingOverlay`. All mocks are `vi.hoisted`
module-level. `mockStore` is `reactive()` because the source mutates
`activeLayer` (visible in template binding), `maskCanvas`, etc.; the
rest are plain function bags.
- Child components are stubbed to bare `<div data-testid>` so init
reveal can be asserted via `screen.findByTestId(...)` without engaging
their real implementations (each has its own test file).
- `MockResizeObserver` captures the constructor callback in module-level
`lastResizeCallback`. The "invalidate on resize" test invokes it
manually with empty args — that's enough to exercise the source's `if
(panZoom) { await panZoom.invalidatePanZoom() }` branch since the
callback only consumes `panZoom` from closure.
- happy-dom doesn't propagate `ctrlKey` through the `DragEvent`
constructor, so the drag tests set it via `Object.defineProperty(event,
'ctrlKey', { value })` (same pattern used in `PointerZone.test.ts` for
wheel `clientX/Y`).
- 94.11% line coverage — the two uncovered blocks (`containerRef`
missing, canvas refs missing) are early-return error paths unreachable
when Vue successfully mounts; not worth constructing a fixture to
trigger.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` mocks reset via `beforeEach`,
`screen.findByTestId` for async render assertions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11651-test-add-unit-tests-for-MaskEditorContent-main-container-34e6d73d365081b38af2e057cb7daf9e)
by [Unito](https://www.unito.io)
2026-04-26 17:52:29 -04:00
Terry Jia
492bec28c8 test: add unit tests for TopBarHeader (#11650)
## Summary

Add unit tests for `TopBarHeader` mask editor dialog component, raising
coverage from 0% to **100%** across statements, branches, functions, and
lines.

## Changes

- **What**: Add `src/components/maskeditor/dialog/TopBarHeader.test.ts`
(17 tests) covering:
  - Localized title rendering.
  - Undo / Redo buttons forward to `store.canvasHistory.{undo,redo}`.
- Four transform buttons (rotate left / right, mirror horizontal /
vertical) call the matching `canvasTransform` action — parametrized via
`it.each`.
- All four transform error paths: rejected promise is caught, swallowed,
and logged with the right `[TopBarHeader] ... failed:` prefix.
- Invert calls `canvasTools.invertMask`; Clear calls both
`canvasTools.clearMask` and `store.triggerClear`.
- Save: hides brush, awaits `saver.save()`, closes the dialog on
success; switches button text to "Saving" while in-flight; restores
brush + button label and logs on save failure.
  - Cancel: closes the dialog with the `global-mask-editor` key.

## Review Focus

- All five composable / store dependencies are mocked at module level
via `vi.hoisted`: `useMaskEditorStore`, `useDialogStore`,
`useCanvasTools`, `useCanvasTransform`, `useMaskEditorSaver`. Only the
store needs `reactive()` (`brushVisible` flips during save flow); the
rest are plain function bags.
- `Button.vue` is stubbed to a thin `<button :disabled>` so role queries
(`getByRole('button', { name: ... })`) resolve cleanly without dragging
in the real UI button's classes / variants.
- Real i18n via `createI18n`. Icon buttons rely on `:title` for their
accessible name; text buttons rely on slot text — both are reachable
through `getByRole('button', { name: ... })`.
- The "Saving" text test uses an unresolved promise to keep the
in-flight state observable; `waitFor` + `void user.click(...)` lets us
assert without awaiting the click. The dangling promise resolves at the
end so vitest doesn't complain.
- `it.each` parametrizes the four transform success paths and
(separately) the four error paths, keeping the file tight.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for cross-test mocks, real i18n with
`createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11650-test-add-unit-tests-for-TopBarHeader-34e6d73d365081adab66e460cf56accb)
by [Unito](https://www.unito.io)
2026-04-26 17:51:44 -04:00
51 changed files with 4682 additions and 378 deletions

147
.github/workflows/ci-deploy-preview.yaml vendored Normal file
View File

@@ -0,0 +1,147 @@
# Description: Builds ComfyUI frontend and deploys previews to Cloudflare Pages
name: 'CI: Deploy Preview'
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
./scripts/cicd/pr-preview-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"starting"
# Build frontend for all PRs and pushes
build:
runs-on: ubuntu-latest
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build frontend
env:
FRONTEND_COMMIT_HASH: ${{ github.sha }}
CI_BRANCH: ${{ github.head_ref || github.ref_name }}
CI_PR_NUMBER: ${{ github.event.pull_request.number || '' }}
CI_PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }}
CI_RUN_ID: ${{ github.run_id }}
CI_JOB_ID: ${{ github.job }}
USE_PROD_CONFIG: 'true'
run: pnpm build
- name: Set job status
id: job-status
if: always()
run: |
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
- name: Get workflow URL
id: workflow-url
if: always()
run: |
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
- name: Upload build artifact
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v6
with:
name: dist
path: dist/
retention-days: 7
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [comment-on-pr-start, build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Download build artifact
if: needs.build.outputs.conclusion == 'success'
uses: actions/download-artifact@v7
with:
name: dist
path: dist
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
- name: Deploy preview and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ needs.build.outputs.conclusion }}
WORKFLOW_URL: ${{ needs.build.outputs.workflow-url }}
BRANCH_NAME: ${{ github.head_ref }}
run: |
./scripts/cicd/pr-preview-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"completed"
# Deploy to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build frontend
env:
FRONTEND_COMMIT_HASH: ${{ github.sha }}
CI_BRANCH: ${{ github.ref_name }}
CI_RUN_ID: ${{ github.run_id }}
CI_JOB_ID: ${{ github.job }}
run: pnpm build
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
pnpm dlx wrangler@^4.0.0 pages deploy dist \
--project-name=comfy-ui \
--branch=main

3
.gitignore vendored
View File

@@ -99,4 +99,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.claude/scheduled_tasks.lock

View File

@@ -7,6 +7,12 @@ export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},

View File

@@ -119,7 +119,15 @@
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
],
"properties": {},
"properties": {
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
}
],

View File

@@ -261,6 +261,7 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
public readonly filterButton: Locator
// --- View mode ---
public readonly listViewOption: Locator
@@ -299,6 +300,7 @@ export class AssetsSidebarTab extends SidebarTab {
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.filterButton = page.getByRole('button', { name: 'Filter by' })
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.sortNewestFirst = page.getByText('Newest first')
@@ -334,6 +336,10 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText(title)
}
filterCheckbox(label: string) {
return this.page.getByRole('checkbox', { name: label })
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
}
@@ -383,6 +389,16 @@ export class AssetsSidebarTab extends SidebarTab {
.waitFor({ state: 'visible', timeout: 3000 })
}
async openFilterMenu() {
await this.dismissToasts()
await this.filterButton.click()
// Wait for popover content with checkboxes to render
await this.filterCheckbox('Image').waitFor({
state: 'visible',
timeout: 3000
})
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })

View File

@@ -772,3 +772,119 @@ test.describe('Assets sidebar - delete confirmation', () => {
await expect(tab.assetCards).toHaveCount(initialCount)
})
})
// ==========================================================================
// 12. Media type filter (cloud-only)
// ==========================================================================
const MIXED_MEDIA_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-image',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'photo.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-video',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2010,
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'video'
},
outputs_count: 1
}),
createMockJob({
id: 'job-audio',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3010,
preview_output: {
filename: 'track.mp3',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'audio'
},
outputs_count: 1
})
]
// Filter button is guarded by isCloud (compile-time). The cloud CI project
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
// supports authenticated comfyPage setup.
test.describe('Assets sidebar - media type filter', () => {
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MIXED_MEDIA_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Filter menu shows media type options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openFilterMenu()
await expect(tab.filterCheckbox('Image')).toBeVisible()
await expect(tab.filterCheckbox('Video')).toBeVisible()
await expect(tab.filterCheckbox('Audio')).toBeVisible()
await expect(tab.filterCheckbox('3D')).toBeVisible()
})
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = tab.assetCards
await expect(
initialCount,
'All three mixed-media jobs should render'
).toHaveCount(3)
// Open filter menu and enable only image filter (selecting a filter
// restricts to that type only, hiding unselected types)
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
// Only the image asset should remain
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
})
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Enable image filter to restrict to images only
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
// Uncheck image filter to remove all filters (restores all assets)
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
})

View File

@@ -0,0 +1,78 @@
# 案 B: バックエンドの `/features` に `comfy_api_base` を追加する
## 背景
ComfyUI バックエンドは `--comfy-api-base` CLI フラグで Comfy Cloud の API ベース URLprod / staging / カスタム)を選択する。
フロントエンドは `__USE_PROD_CONFIG__` ビルド時定数で同じ値を選ぶ。
両者が食い違うと、フロントエンドが発行した Firebase トークン(または API キー)が
バックエンド経由で別の環境に投げられ、認証や課金が落ちる。
現状の検出方法(案 A、`src/views/ConnectionPanelView.vue`)は
`/api/system_stats``system.argv`CLI 全引数)から `--comfy-api-base` を grep するもの。
動くが脆い:
- 引数の書式(`--flag VALUE` vs `--flag=VALUE`)に依存する
- バックエンド側の CLI シグネチャが変わると壊れる
- 「公開 API ではない情報」を検出ロジックに使っている
## 提案
ComfyUI 本体の `/features` エンドポイントに `comfy_api_base` を追加する。
`/features` はすでに「構造化された機能/設定の公開 API」という位置付けがあり、ここに含めるのが自然。
### バックエンドの実装スケッチ
```python
# tmp/ComfyUI/comfy_api/feature_flags.py:65 付近
def get_server_features() -> dict[str, Any]:
from comfy.cli_args import args
return {
...,
"comfy_api_base": args.comfy_api_base,
}
```
### フロントエンドの変更
```ts
// 例: src/platform/connectionPanel/ あたりに移設
const features = await fetch(`${base}/api/features`).then((r) => r.json())
const backendCloudBase =
features.comfy_api_base ?? parseBackendCloudBase(stats.system?.argv)
```
`features.comfy_api_base` を優先し、未定義の場合のみ `argv` フォールバックを使う。
## メリット
- 構造化された公開 API になり、CLI 変更の影響を受けない
- 拡張機能 / カスタムノードからも安定して参照できる
- 既存の `/features` パターン(ファースト クラスのバックエンド能力公開)に合致
- フロントエンドの検出コードが自明になる
## デメリット
- `Comfy-Org/ComfyUI` 本体への PR とリリースが必要
- リリース前は案 A をフォールバックとして残す必要がある
- `comfy_api_base` を「公開してよい情報」と扱う合意が必要
(カスタム URL を使うユーザーには内部 URL が露出することになる)
## ロードマップ
1. **案 A をフロントエンドに実装(このコミット)**
- `ConnectionPanelView.vue``/system_stats``argv` を解析
- 不一致を検出した場合は黄色の警告を表示
2. `Comfy-Org/ComfyUI``/features` 拡張 PR を提出
- `comfy_api/feature_flags.py:65``comfy_api_base` を追加
3. 本体リリース後、フロントエンドを `features.comfy_api_base` 優先に切替
- `argv` フォールバックは互換性のために残す
4. 数バージョン後、`argv` フォールバックを削除
## 関連ファイル
- ComfyUI 本体: `comfy/cli_args.py:229``--comfy-api-base` 引数定義(デフォルト `https://api.comfy.org`
- ComfyUI 本体: `comfy_api/feature_flags.py:65``get_server_features()` の現状
- ComfyUI 本体: `server.py:646-685``/system_stats` ハンドラ(`argv` を返している)
- フロントエンド: `src/config/comfyApi.ts:21-31``getComfyApiBaseUrl()`(フロント側のビルド時定数)
- フロントエンド: `src/views/ConnectionPanelView.vue` — 案 A 実装場所
- フロントエンド: `src/platform/remoteConfig/refreshRemoteConfig.ts``/features` 既存利用

View File

@@ -27,7 +27,12 @@ const commonGlobals = {
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
__IS_NIGHTLY__: 'readonly',
__CI_BRANCH__: 'readonly',
__CI_PR_NUMBER__: 'readonly',
__CI_PR_AUTHOR__: 'readonly',
__CI_RUN_ID__: 'readonly',
__CI_JOB_ID__: 'readonly'
} as const
const settings = {

5
global.d.ts vendored
View File

@@ -2,6 +2,11 @@ declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __CI_BRANCH__: string
declare const __CI_PR_NUMBER__: string
declare const __CI_PR_AUTHOR__: string
declare const __CI_RUN_ID__: string
declare const __CI_JOB_ID__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean

View File

@@ -0,0 +1,209 @@
#!/bin/bash
set -e
# Deploy frontend preview to Cloudflare Pages and comment on PR
# Usage: ./pr-preview-deploy-and-comment.sh <pr_number> <status>
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Validate status parameter
STATUS="${2:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- COMFYUI_PREVIEW_DEPLOY -->"
# Resolve wrangler invocation: prefer a locally-available binary, otherwise
# run via pnpm dlx to honour the repo's package-manager policy.
if command -v wrangler > /dev/null 2>&1; then
WRANGLER="wrangler"
else
WRANGLER="pnpm dlx wrangler@^4.0.0"
fi
# Deploy frontend preview, WARN: ensure inputs are sanitized before calling this function
deploy_preview() {
dir="$1"
branch="$2"
[ ! -d "$dir" ] && echo "failed" && return
project="comfy-ui"
echo "Deploying frontend preview to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch is already sanitized, use it directly
if output=$($WRANGLER pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Prefer the branch alias URL over the deployment hash URL so the
# link in the PR comment stays stable across redeploys.
branch_url="https://${branch}.${project}.pages.dev"
if echo "$output" | grep -qF "$branch_url"; then
result="$branch_url"
else
# Fall back to first pages.dev URL in wrangler output
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-$branch_url}"
fi
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
comment="$COMMENT_MARKER
## 🌐 Frontend Preview: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
post_comment "$comment"
elif [ "$STATUS" = "completed" ]; then
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
# Falls back to pr-$PR_NUMBER if BRANCH_NAME is unset
if [ -n "$BRANCH_NAME" ]; then
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
else
cloudflare_branch="pr-$PR_NUMBER"
fi
echo "Looking for frontend build in: $(pwd)/dist"
# Deploy preview if build exists
deployment_url="Not deployed"
if [ -d "dist" ]; then
echo "Found frontend build, deploying..."
url=$(deploy_preview "dist" "$cloudflare_branch")
if [ "$url" != "failed" ] && [ -n "$url" ]; then
deployment_url="[🌐 Open Preview]($url)"
else
deployment_url="Deployment failed"
fi
else
echo "Frontend build not found at dist"
fi
# Get workflow conclusion from environment or default to success
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
WORKFLOW_URL="${WORKFLOW_URL:-}"
# Generate compact header based on conclusion
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
status_icon="✅"
status_text="Built"
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
status_icon="⏭️"
status_text="Skipped"
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
status_icon="🚫"
status_text="Cancelled"
else
status_icon="❌"
status_text="Failed"
fi
# Build compact header with optional preview link
header="## 🌐 Frontend Preview: $status_icon $status_text"
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
header="$header$deployment_url"
fi
# Build details section
details="<details>
<summary>Details</summary>
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
**Links**
- [📊 View Workflow Run]($WORKFLOW_URL)"
if [ "$deployment_url" != "Not deployed" ]; then
if [ "$deployment_url" = "Deployment failed" ]; then
details="$details
- ❌ Preview deployment failed"
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⚠️ Build failed — $deployment_url"
fi
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⏭️ Preview deployment skipped (build did not succeed)"
fi
details="$details
</details>"
comment="$COMMENT_MARKER
$header
$details"
post_comment "$comment"
fi

View File

@@ -0,0 +1,33 @@
<template>
<div class="relative">
<code
class="block rounded-md bg-neutral-800 p-3 pr-10 text-xs whitespace-pre-wrap text-neutral-200 select-all"
>{{ text }}</code
>
<button
:title="copied ? t('clipboard.successMessage') : t('g.copyToClipboard')"
:aria-label="
copied ? t('clipboard.successMessage') : t('g.copyToClipboard')
"
class="absolute top-2 right-2 rounded-sm p-1 text-neutral-500 transition-colors hover:text-neutral-100"
@click="copy(text)"
>
<span
:class="
copied ? 'icon-[lucide--check] text-green-400' : 'icon-[lucide--copy]'
"
class="block size-3.5"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
const { text } = defineProps<{ text: string }>()
const { t } = useI18n()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
</script>

View File

@@ -0,0 +1,354 @@
import { render, screen, waitFor } from '@testing-library/vue'
import { reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
const mockKeyboard = vi.hoisted(() => ({
addListeners: vi.fn(),
removeListeners: vi.fn()
}))
const mockPanZoom = vi.hoisted(() => ({
initializeCanvasPanZoom: vi.fn().mockResolvedValue(undefined),
invalidatePanZoom: vi.fn().mockResolvedValue(undefined)
}))
const mockBrushDrawing = vi.hoisted(() => ({
initGPUResources: vi.fn().mockResolvedValue(undefined),
initPreviewCanvas: vi.fn(),
saveBrushSettings: vi.fn()
}))
const mockToolManager = vi.hoisted(() => ({
brushDrawing: mockBrushDrawing
}))
const mockImageLoader = vi.hoisted(() => ({
loadImages: vi.fn().mockResolvedValue({ width: 100, height: 100 })
}))
const mockMaskEditorLoader = vi.hoisted(() => ({
loadFromNode: vi.fn().mockResolvedValue(undefined)
}))
const mockCanvasHistory = vi.hoisted(() => ({
saveInitialState: vi.fn(),
clearStates: vi.fn()
}))
const initialMockStore = () =>
reactive({
activeLayer: 'mask' as 'mask' | 'rgb',
maskCanvas: null as HTMLCanvasElement | null,
rgbCanvas: null as HTMLCanvasElement | null,
imgCanvas: null as HTMLCanvasElement | null,
canvasContainer: null as HTMLElement | null,
canvasBackground: null as HTMLElement | null,
canvasHistory: mockCanvasHistory,
resetState: vi.fn()
})
let mockStore: ReturnType<typeof initialMockStore>
const mockDataStore = vi.hoisted(() => ({
reset: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
vi.mock('@/composables/maskeditor/useKeyboard', () => ({
useKeyboard: () => mockKeyboard
}))
vi.mock('@/composables/maskeditor/usePanAndZoom', () => ({
usePanAndZoom: () => mockPanZoom
}))
vi.mock('@/composables/maskeditor/useToolManager', () => ({
useToolManager: () => mockToolManager
}))
vi.mock('@/composables/maskeditor/useImageLoader', () => ({
useImageLoader: () => mockImageLoader
}))
vi.mock('@/composables/maskeditor/useMaskEditorLoader', () => ({
useMaskEditorLoader: () => mockMaskEditorLoader
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: () => mockDataStore
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
default: {
name: 'LoadingOverlayStub',
props: ['loading', 'size'],
template: `<div data-testid="loading-overlay" :data-loading="loading" />`
}
}))
vi.mock('@/components/maskeditor/ToolPanel.vue', () => ({
default: {
name: 'ToolPanelStub',
props: ['toolManager'],
template: '<div data-testid="tool-panel-stub" />'
}
}))
vi.mock('@/components/maskeditor/PointerZone.vue', () => ({
default: {
name: 'PointerZoneStub',
props: ['toolManager', 'panZoom'],
template: '<div data-testid="pointer-zone-stub" />'
}
}))
vi.mock('@/components/maskeditor/SidePanel.vue', () => ({
default: {
name: 'SidePanelStub',
props: ['toolManager'],
template: '<div data-testid="side-panel-stub" />'
}
}))
vi.mock('@/components/maskeditor/BrushCursor.vue', () => ({
default: {
name: 'BrushCursorStub',
props: ['containerRef'],
template: '<div data-testid="brush-cursor-stub" />'
}
}))
const observeSpy = vi.fn()
const disconnectSpy = vi.fn()
let lastResizeCallback: ResizeObserverCallback | null = null
class MockResizeObserver {
observe = observeSpy
disconnect = disconnectSpy
unobserve = vi.fn()
constructor(cb: ResizeObserverCallback) {
lastResizeCallback = cb
}
}
// `node` only flows into mocked `loader.loadFromNode`, so a typed sentinel
// with a stable identity is enough — we never read its fields.
const fakeNode = { id: 1, title: 'test-node' } as unknown as LGraphNode
const renderContent = () =>
render(MaskEditorContent, { props: { node: fakeNode } })
let originalResizeObserver: typeof ResizeObserver | undefined
describe('MaskEditorContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMockStore()
mockMaskEditorLoader.loadFromNode.mockResolvedValue(undefined)
mockImageLoader.loadImages.mockResolvedValue({ width: 100, height: 100 })
mockPanZoom.initializeCanvasPanZoom.mockResolvedValue(undefined)
mockBrushDrawing.initGPUResources.mockResolvedValue(undefined)
originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver
})
afterEach(() => {
globalThis.ResizeObserver =
originalResizeObserver as unknown as typeof ResizeObserver
})
describe('mount', () => {
it('should add keyboard listeners on mount', () => {
renderContent()
expect(mockKeyboard.addListeners).toHaveBeenCalledTimes(1)
})
it('should observe the container with a ResizeObserver', async () => {
renderContent()
await waitFor(() => expect(observeSpy).toHaveBeenCalledTimes(1))
})
it('should invalidate pan/zoom on resize', async () => {
renderContent()
await waitFor(() => expect(observeSpy).toHaveBeenCalled())
mockPanZoom.invalidatePanZoom.mockClear()
lastResizeCallback?.([], {} as ResizeObserver)
await new Promise((r) => setTimeout(r, 0))
expect(mockPanZoom.invalidatePanZoom).toHaveBeenCalledTimes(1)
})
it('should assign canvas refs to the store before init', async () => {
renderContent()
await waitFor(() => expect(mockStore.maskCanvas).not.toBeNull())
expect(mockStore.rgbCanvas).not.toBeNull()
expect(mockStore.imgCanvas).not.toBeNull()
expect(mockStore.canvasContainer).not.toBeNull()
expect(mockStore.canvasBackground).not.toBeNull()
})
})
describe('init flow', () => {
it('should run the init chain in the documented order', async () => {
renderContent()
await waitFor(() => {
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
})
const orderOf = (fn: { mock: { invocationCallOrder: number[] } }) =>
fn.mock.invocationCallOrder[0]
expect(orderOf(mockMaskEditorLoader.loadFromNode)).toBeLessThan(
orderOf(mockImageLoader.loadImages)
)
expect(orderOf(mockImageLoader.loadImages)).toBeLessThan(
orderOf(mockPanZoom.initializeCanvasPanZoom)
)
expect(orderOf(mockPanZoom.initializeCanvasPanZoom)).toBeLessThan(
orderOf(mockCanvasHistory.saveInitialState)
)
expect(orderOf(mockCanvasHistory.saveInitialState)).toBeLessThan(
orderOf(mockBrushDrawing.initGPUResources)
)
expect(orderOf(mockBrushDrawing.initGPUResources)).toBeLessThan(
orderOf(mockBrushDrawing.initPreviewCanvas)
)
expect(mockMaskEditorLoader.loadFromNode).toHaveBeenCalledWith(fakeNode)
})
it('should reveal the child UI components after init succeeds', async () => {
renderContent()
expect(await screen.findByTestId('tool-panel-stub')).toBeInTheDocument()
expect(await screen.findByTestId('pointer-zone-stub')).toBeInTheDocument()
expect(await screen.findByTestId('side-panel-stub')).toBeInTheDocument()
expect(await screen.findByTestId('brush-cursor-stub')).toBeInTheDocument()
})
it('should size the GPU preview canvas to match the mask canvas', async () => {
// Force the mask canvas to non-default dimensions during init so the
// assertion below proves the source actually copies width/height across
// (default 300x150 on both would make the test tautological).
mockBrushDrawing.initGPUResources.mockImplementationOnce(async () => {
if (mockStore.maskCanvas) {
mockStore.maskCanvas.width = 999
mockStore.maskCanvas.height = 777
}
})
renderContent()
await waitFor(() => {
expect(mockBrushDrawing.initPreviewCanvas).toHaveBeenCalled()
})
const previewCanvas = mockBrushDrawing.initPreviewCanvas.mock
.calls[0][0] as HTMLCanvasElement
expect(previewCanvas.width).toBe(999)
expect(previewCanvas.height).toBe(777)
})
})
describe('init error', () => {
it('should close the dialog and log when loader.loadFromNode rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockMaskEditorLoader.loadFromNode.mockRejectedValueOnce(
new Error('load failed')
)
renderContent()
await waitFor(() => {
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorContent] Initialization failed:',
expect.any(Error)
)
errorSpy.mockRestore()
})
it('should close the dialog and log when initializeCanvasPanZoom rejects', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPanZoom.initializeCanvasPanZoom.mockRejectedValueOnce(
new Error('panzoom failed')
)
renderContent()
await waitFor(() => {
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
expect(errorSpy).toHaveBeenCalledWith(
'[MaskEditorContent] Initialization failed:',
expect.any(Error)
)
errorSpy.mockRestore()
})
})
describe('drag handling', () => {
it('should prevent default on dragstart with Ctrl held', () => {
renderContent()
const root = screen.getByTestId('mask-editor-root')
const event = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
// happy-dom doesn't propagate ctrlKey through the DragEvent constructor.
Object.defineProperty(event, 'ctrlKey', { value: true })
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('should not prevent default on plain dragstart without Ctrl', () => {
renderContent()
const root = screen.getByTestId('mask-editor-root')
const event = new DragEvent('dragstart', {
bubbles: true,
cancelable: true
})
Object.defineProperty(event, 'ctrlKey', { value: false })
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
describe('unmount cleanup', () => {
it('should run the full cleanup chain on unmount', async () => {
const { unmount } = renderContent()
await waitFor(() =>
expect(mockBrushDrawing.initGPUResources).toHaveBeenCalled()
)
unmount()
expect(mockBrushDrawing.saveBrushSettings).toHaveBeenCalledTimes(1)
expect(mockKeyboard.removeListeners).toHaveBeenCalledTimes(1)
expect(mockCanvasHistory.clearStates).toHaveBeenCalledTimes(1)
expect(mockStore.resetState).toHaveBeenCalledTimes(1)
expect(mockDataStore.reset).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
ref="containerRef"
data-testid="mask-editor-root"
class="maskEditor-dialog-root flex size-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"

View File

@@ -0,0 +1,262 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
const mockCanvasHistory = vi.hoisted(() => ({
undo: vi.fn(),
redo: vi.fn()
}))
const initialMock = () =>
reactive({
canvasHistory: mockCanvasHistory,
brushVisible: true,
triggerClear: vi.fn()
})
let mockStore: ReturnType<typeof initialMock>
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
const mockCanvasTools = vi.hoisted(() => ({
invertMask: vi.fn(),
clearMask: vi.fn()
}))
const mockCanvasTransform = vi.hoisted(() => ({
rotateCounterclockwise: vi.fn().mockResolvedValue(undefined),
rotateClockwise: vi.fn().mockResolvedValue(undefined),
mirrorHorizontal: vi.fn().mockResolvedValue(undefined),
mirrorVertical: vi.fn().mockResolvedValue(undefined)
}))
const mockSaver = vi.hoisted(() => ({
save: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: () => mockStore
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/composables/maskeditor/useCanvasTools', () => ({
useCanvasTools: () => mockCanvasTools
}))
vi.mock('@/composables/maskeditor/useCanvasTransform', () => ({
useCanvasTransform: () => mockCanvasTransform
}))
vi.mock('@/composables/maskeditor/useMaskEditorSaver', () => ({
useMaskEditorSaver: () => mockSaver
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: {
name: 'ButtonStub',
props: ['variant', 'disabled'],
template:
'<button :data-variant="variant" :disabled="disabled"><slot /></button>'
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
save: 'Save',
saving: 'Saving',
cancel: 'Cancel'
},
maskEditor: {
title: 'Mask Editor',
invert: 'Invert',
clear: 'Clear',
undo: 'Undo',
redo: 'Redo',
rotateLeft: 'Rotate Left',
rotateRight: 'Rotate Right',
mirrorHorizontal: 'Mirror Horizontal',
mirrorVertical: 'Mirror Vertical'
}
}
}
})
const renderHeader = () => render(TopBarHeader, { global: { plugins: [i18n] } })
describe('TopBarHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStore = initialMock()
})
describe('title', () => {
it('should render the localized title', () => {
renderHeader()
expect(screen.getByText('Mask Editor')).toBeInTheDocument()
})
})
describe('history buttons', () => {
it('should call canvasHistory.undo when undo button is clicked', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Undo' }))
expect(mockCanvasHistory.undo).toHaveBeenCalledTimes(1)
})
it('should call canvasHistory.redo when redo button is clicked', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Redo' }))
expect(mockCanvasHistory.redo).toHaveBeenCalledTimes(1)
})
})
describe('canvas transform buttons', () => {
it.each([
['Rotate Left', 'rotateCounterclockwise'],
['Rotate Right', 'rotateClockwise'],
['Mirror Horizontal', 'mirrorHorizontal'],
['Mirror Vertical', 'mirrorVertical']
] as const)(
'should call canvasTransform.%s when %s button is clicked',
async (label, method) => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: label }))
expect(mockCanvasTransform[method]).toHaveBeenCalledTimes(1)
}
)
it.each([
['Rotate Left', 'rotateCounterclockwise', 'Rotate left failed:'],
['Rotate Right', 'rotateClockwise', 'Rotate right failed:'],
['Mirror Horizontal', 'mirrorHorizontal', 'Mirror horizontal failed:'],
['Mirror Vertical', 'mirrorVertical', 'Mirror vertical failed:']
] as const)(
'should swallow and log errors from %s',
async (label, method, expectedMsg) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockCanvasTransform[method].mockRejectedValueOnce(new Error('boom'))
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: label }))
expect(errorSpy).toHaveBeenCalledWith(
`[TopBarHeader] ${expectedMsg}`,
expect.any(Error)
)
errorSpy.mockRestore()
}
)
})
describe('mask edit buttons', () => {
it('should call canvasTools.invertMask on Invert click', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Invert' }))
expect(mockCanvasTools.invertMask).toHaveBeenCalledTimes(1)
})
it('should call clearMask and store.triggerClear on Clear click', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Clear' }))
expect(mockCanvasTools.clearMask).toHaveBeenCalledTimes(1)
expect(mockStore.triggerClear).toHaveBeenCalledTimes(1)
})
})
describe('save', () => {
it('should hide brush, save, and close the dialog on success', async () => {
const user = userEvent.setup()
renderHeader()
mockStore.brushVisible = true
await user.click(screen.getByRole('button', { name: /save/i }))
expect(mockStore.brushVisible).toBe(false)
expect(mockSaver.save).toHaveBeenCalledTimes(1)
expect(mockDialogStore.closeDialog).toHaveBeenCalledTimes(1)
})
it('should switch the button text to "Saving" and disable the button while saving', async () => {
let resolve!: () => void
mockSaver.save.mockReturnValueOnce(
new Promise<void>((r) => {
resolve = r
})
)
const user = userEvent.setup()
renderHeader()
const clickPromise = user.click(
screen.getByRole('button', { name: /save/i })
)
const savingBtn = await screen.findByRole('button', { name: 'Saving' })
expect(savingBtn).toBeDisabled()
resolve()
await clickPromise
})
it('should restore brush + button state and log on save failure', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSaver.save.mockRejectedValueOnce(new Error('save failed'))
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: /save/i }))
expect(mockStore.brushVisible).toBe(true)
expect(errorSpy).toHaveBeenCalledWith(
'[TopBarHeader] Save failed:',
expect.any(Error)
)
expect(mockDialogStore.closeDialog).not.toHaveBeenCalled()
// After failure, the Save button reads "Save" again (not "Saving")
expect(
screen.getByRole('button', { name: /save/i }).textContent?.trim()
).toBe('Save')
errorSpy.mockRestore()
})
})
describe('cancel', () => {
it('should close the dialog with the global-mask-editor key', async () => {
const user = userEvent.setup()
renderHeader()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-mask-editor'
})
})
})
})

View File

@@ -42,6 +42,19 @@
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
<template v-if="badge.popoverLinks?.length">
<hr class="border-border-default" />
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</template>
</div>
</Popover>
</div>
@@ -96,6 +109,19 @@
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
<template v-if="badge.popoverLinks?.length">
<hr class="border-border-default" />
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</template>
</div>
</Popover>
</div>
@@ -103,10 +129,15 @@
<!-- Full mode: Icon + Label + Text -->
<div
v-else
v-tooltip="badge.tooltip"
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
v-tooltip="badge.popoverLinks?.length ? undefined : badge.tooltip"
class="relative flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[
{ 'flex-row-reverse': reverseOrder },
noPadding ? '' : 'px-3',
badge.popoverLinks?.length ? clickableClasses : ''
]"
:style="menuBackgroundStyle"
@click="badge.popoverLinks?.length ? togglePopover($event) : undefined"
>
<i
v-if="iconClass"
@@ -123,6 +154,30 @@
<div class="font-inter text-sm" :class="textClasses">
{{ badge.text }}
</div>
<Popover
v-if="badge.popoverLinks?.length"
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="popoverPt"
>
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</div>
</Popover>
</div>
</template>
<script setup lang="ts">

View File

@@ -41,3 +41,15 @@ export function getComfyPlatformBaseUrl(): string {
BUILD_TIME_PLATFORM_BASE_URL
)
}
/**
* Maps a Comfy Cloud API base URL (as reported by the backend) to its paired
* platform URL where users manage their account / API keys. Returns null for
* unknown bases so callers can hide the link rather than guess.
*/
export function getPlatformBaseUrlForApiBase(apiBase: string): string | null {
const normalized = apiBase.replace(/\/+$/, '')
if (normalized === PROD_API_BASE_URL) return PROD_PLATFORM_BASE_URL
if (normalized === STAGING_API_BASE_URL) return STAGING_PLATFORM_BASE_URL
return null
}

View File

@@ -52,3 +52,8 @@ if (isCloud || isNightly) {
if (isNightly && !isCloud) {
await import('./nightlyBadges')
}
// PR preview build badge
if (__CI_PR_NUMBER__) {
await import('./prPreviewBadges')
}

View File

@@ -469,6 +469,31 @@ describe('Load3d', () => {
})
})
describe('adapter-driven kind queries', () => {
function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) {
const adapter = kind === null ? null : { kind }
Object.assign(ctx.load3d, {
loaderManager: { getCurrentAdapter: vi.fn(() => adapter) }
})
}
it('isSplatModel is true only when the current adapter kind is "splat"', () => {
makeWithAdapter('splat')
expect(ctx.load3d.isSplatModel()).toBe(true)
makeWithAdapter('mesh')
expect(ctx.load3d.isSplatModel()).toBe(false)
makeWithAdapter(null)
expect(ctx.load3d.isSplatModel()).toBe(false)
})
it('isPlyModel is true only when the current adapter kind is "pointCloud"', () => {
makeWithAdapter('pointCloud')
expect(ctx.load3d.isPlyModel()).toBe(true)
makeWithAdapter('mesh')
expect(ctx.load3d.isPlyModel()).toBe(false)
})
})
describe('captureScene', () => {
it('hides the gizmo helper during capture and restores it after success', async () => {
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -24,6 +22,7 @@ import type {
MaterialMode,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
@@ -78,10 +77,7 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
@@ -217,69 +213,12 @@ class Load3d {
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
*/
private initContextMenu(): void {
const canvas = this.renderer.domElement
this.contextMenuAbortController = new AbortController()
const { signal } = this.contextMenuAbortController
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
this.rightMouseMoved = true
}
}
}
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const wasDragging =
this.rightMouseMoved ||
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
this.rightMouseMoved = false
if (wasDragging) {
return
}
e.preventDefault()
e.stopPropagation()
this.showNodeContextMenu(e)
}
canvas.addEventListener('mousedown', mousedownHandler, { signal })
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
}
private showNodeContextMenu(event: MouseEvent): void {
if (this.onContextMenuCallback) {
this.onContextMenuCallback(event)
}
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
@@ -628,11 +567,11 @@ class Load3d {
}
isSplatModel(): boolean {
return this.modelManager.containsSplatMesh()
return this.loaderManager.getCurrentAdapter()?.kind === 'splat'
}
isPlyModel(): boolean {
return this.modelManager.originalModel instanceof THREE.BufferGeometry
return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud'
}
clearModel(): void {
@@ -915,10 +854,8 @@ class Load3d {
this.resizeObserver = null
}
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement

View File

@@ -0,0 +1,530 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
EventManagerInterface,
MaterialMode,
ModelManagerInterface
} from './interfaces'
import { LoaderManager } from './LoaderManager'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
function makeEventManagerStub() {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
}
type ModelManagerStub = {
clearModel: ReturnType<typeof vi.fn>
setupModel: ReturnType<typeof vi.fn>
setOriginalModel: ReturnType<typeof vi.fn>
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]>
standardMaterial: THREE.MeshStandardMaterial
materialMode: MaterialMode
originalFileName: string | null
originalURL: string | null
}
function makeModelManagerStub(): ModelManagerStub {
return {
clearModel: vi.fn(),
setupModel: vi.fn().mockResolvedValue(undefined),
setOriginalModel: vi.fn(),
originalMaterials: new WeakMap(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode: 'original',
originalFileName: 'model',
originalURL: null
}
}
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
vi.hoisted(() => ({
meshLoad: vi.fn(),
splatLoad: vi.fn(),
pointCloudLoad: vi.fn(),
getPLYEngineMock: vi.fn<() => string>(),
addAlert: vi.fn()
}))
vi.mock('./MeshModelAdapter', () => ({
MeshModelAdapter: class {
readonly kind = 'mesh' as const
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
readonly capabilities = {}
load = meshLoad
}
}))
vi.mock('./PointCloudModelAdapter', () => ({
PointCloudModelAdapter: class {
readonly kind = 'pointCloud' as const
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 capabilities = {}
load = splatLoad
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert })
}))
type LoaderManagerInternals = {
pickAdapter(extension: string): ModelAdapter | null
}
function makeLoaderManager() {
const modelManager = makeModelManagerStub()
const eventManager = makeEventManagerStub()
const lm = new LoaderManager(
modelManager as unknown as ConstructorParameters<typeof LoaderManager>[0],
eventManager
)
const internals = lm as unknown as LoaderManagerInternals
return {
lm,
modelManager,
eventManager,
pick: internals.pickAdapter.bind(lm)
}
}
describe('LoaderManager', () => {
beforeEach(() => {
vi.clearAllMocks()
getPLYEngineMock.mockReturnValue('three')
meshLoad.mockResolvedValue(null)
splatLoad.mockResolvedValue(null)
pointCloudLoad.mockResolvedValue(null)
})
describe('getCurrentAdapter', () => {
it('returns null before any model loads', () => {
const { lm } = makeLoaderManager()
expect(lm.getCurrentAdapter()).toBeNull()
})
it('exposes the picked adapter after a successful load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
})
it('resets to null at the start of a new load', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
await lm.loadModel('api/view?filename=cube.xyz')
expect(lm.getCurrentAdapter()).toBeNull()
})
it('stays null when the adapter rejects', async () => {
const { lm } = makeLoaderManager()
// Seed with a previously-successful mesh load so we can prove a later
// failed splat load does not leave the splat adapter published.
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
splatLoad.mockRejectedValueOnce(new Error('boom'))
vi.spyOn(console, 'error').mockImplementation(() => {})
await lm.loadModel('api/view?filename=scan.splat')
expect(lm.getCurrentAdapter()).toBeNull()
})
it('stays null when the adapter resolves null (parse failure)', async () => {
const { lm } = makeLoaderManager()
pointCloudLoad.mockResolvedValueOnce(null)
await lm.loadModel('api/view?filename=scan.ply')
expect(lm.getCurrentAdapter()).toBeNull()
})
})
describe('loadModel ordering', () => {
it('keeps the old adapter current while clearModel runs (so future dispose hooks see it)', async () => {
const oldAdapter = {
kind: 'splat' as const,
extensions: ['splat'] as const,
capabilities: {
fitToViewer: false,
requiresMaterialRebuild: false,
gizmoTransform: false,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 5
},
load: vi.fn().mockResolvedValue(null)
} satisfies ModelAdapter
const modelManager = {
originalMaterials: new WeakMap(),
clearModel: vi.fn(),
setupModel: vi.fn()
} as unknown as ModelManagerInterface
const eventManager: EventManagerInterface = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
}
let adapterDuringClear: ModelAdapter | null | undefined
const lm = new LoaderManager(modelManager, eventManager, [oldAdapter])
// Prime the loader with an active adapter, then trigger a new load.
;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter =
oldAdapter
;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation(
() => {
adapterDuringClear = lm.getCurrentAdapter()
}
)
await lm.loadModel(
'api/view?type=input&subfolder=&filename=a.splat',
'a.splat'
)
expect(adapterDuringClear).toBe(oldAdapter)
})
})
describe('pickAdapter', () => {
it.each(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
(ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('mesh')
}
)
it.each(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
(ext) => {
const { pick } = makeLoaderManager()
expect(pick(ext)?.kind).toBe('splat')
}
)
it('routes .ply to the point-cloud adapter for the default three engine', () => {
getPLYEngineMock.mockReturnValue('three')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
getPLYEngineMock.mockReturnValue('fastply')
const { pick } = makeLoaderManager()
expect(pick('ply')?.kind).toBe('pointCloud')
})
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
getPLYEngineMock.mockReturnValue('sparkjs')
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()
})
})
describe('loadModel', () => {
it('emits modelLoadingStart and records originalURL before dispatching', async () => {
const { lm, eventManager, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingStart',
null
)
expect(modelManager.originalURL).toBe('api/view?filename=cube.glb')
})
it('clears any existing model before routing to the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const order: string[] = []
modelManager.clearModel.mockImplementation(() => order.push('clear'))
meshLoad.mockImplementationOnce(async () => {
order.push('load')
return null
})
await lm.loadModel('api/view?filename=cube.glb')
expect(order).toEqual(['clear', 'load'])
})
it('derives originalFileName from an explicit originalFileName argument', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=ignored.glb', 'uploads/my-cube.glb')
expect(modelManager.originalFileName).toBe('my-cube')
})
it('derives originalFileName from the URL filename param when no override is given', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.originalFileName).toBe('cube')
})
it('falls back to "model" when the URL has no filename param', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?other=1')
expect(modelManager.originalFileName).toBe('model')
})
it('alerts when the file extension cannot be determined', async () => {
const { lm, modelManager } = makeLoaderManager()
await lm.loadModel('api/view?other=1')
expect(addAlert).toHaveBeenCalledWith(
'toastMessages.couldNotDetermineFileType'
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
expect(meshLoad).not.toHaveBeenCalled()
})
it('passes setupModel the object returned by the adapter', async () => {
const { lm, modelManager } = makeLoaderManager()
const loaded = new THREE.Object3D()
meshLoad.mockResolvedValueOnce(loaded)
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.setupModel).toHaveBeenCalledWith(loaded)
})
it('skips setupModel when the adapter returns null', async () => {
const { lm, modelManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(null)
await lm.loadModel('api/view?filename=cube.glb')
expect(modelManager.setupModel).not.toHaveBeenCalled()
})
it('emits modelLoadingEnd when the load completes', async () => {
const { lm, eventManager } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
})
it('forwards a decoded path and filename to the adapter', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel(
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
)
expect(meshLoad).toHaveBeenCalledWith(
expect.objectContaining({
setOriginalModel: expect.any(Function),
registerOriginalMaterial: expect.any(Function)
}),
'api/view?type=output&subfolder=nested%2Fdir&filename=',
'cube.glb'
)
})
it('defaults the path to type=input when no type param is given', async () => {
const { lm } = makeLoaderManager()
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=cube.glb')
expect(meshLoad).toHaveBeenCalledWith(
expect.anything(),
'api/view?type=input&subfolder=&filename=',
'cube.glb'
)
})
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
getPLYEngineMock.mockReturnValue('sparkjs')
const { lm } = makeLoaderManager()
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
await lm.loadModel('api/view?filename=scan.ply')
expect(splatLoad).toHaveBeenCalled()
expect(pointCloudLoad).not.toHaveBeenCalled()
})
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
const { lm, eventManager } = makeLoaderManager()
const err = new Error('boom')
meshLoad.mockRejectedValueOnce(err)
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
await lm.loadModel('api/view?filename=cube.glb')
expect(eventManager.emitEvent).toHaveBeenCalledWith(
'modelLoadingEnd',
null
)
expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel')
expect(consoleError).toHaveBeenCalled()
})
it('discards the result of a stale load when a newer one has started', async () => {
const { lm, modelManager, eventManager } = makeLoaderManager()
let resolveFirst!: (value: THREE.Object3D) => void
const firstLoad = new Promise<THREE.Object3D>((r) => {
resolveFirst = r
})
const firstModel = new THREE.Object3D()
firstModel.name = 'first'
const secondModel = new THREE.Object3D()
secondModel.name = 'second'
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(secondModel)
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')
resolveFirst(firstModel)
await Promise.all([firstPromise, secondPromise])
expect(modelManager.setupModel).toHaveBeenCalledTimes(1)
expect(modelManager.setupModel).toHaveBeenCalledWith(secondModel)
const endEmits = eventManager.emitEvent.mock.calls.filter(
(call: unknown[]) => call[0] === 'modelLoadingEnd'
)
expect(endEmits).toHaveLength(1)
})
it('logs and drops the load when the URL is missing a filename param', async () => {
const { lm, modelManager } = makeLoaderManager()
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
await lm.loadModel('api/view?type=output', 'uploads/file.glb')
expect(consoleError).toHaveBeenCalledWith(
'Missing filename in URL:',
'api/view?type=output'
)
expect(modelManager.setupModel).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('proxies setOriginalModel and registerOriginalMaterial through the load context', async () => {
const { lm, modelManager } = makeLoaderManager()
let capturedCtx: ModelLoadContext | undefined
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
capturedCtx = ctx
return new THREE.Object3D()
})
await lm.loadModel('api/view?filename=cube.glb')
const mesh = new THREE.Mesh(
new THREE.BufferGeometry(),
new THREE.MeshBasicMaterial()
)
const mat = new THREE.MeshStandardMaterial()
capturedCtx!.setOriginalModel(mesh)
capturedCtx!.registerOriginalMaterial(mesh, mat)
expect(modelManager.setOriginalModel).toHaveBeenCalledWith(mesh)
expect(modelManager.originalMaterials.get(mesh)).toBe(mat)
})
it('exposes modelManager.standardMaterial and materialMode via getters on the load context', async () => {
const { lm, modelManager } = makeLoaderManager()
modelManager.materialMode = 'wireframe'
let capturedCtx: ModelLoadContext | undefined
meshLoad.mockImplementationOnce(async (ctx: ModelLoadContext) => {
capturedCtx = ctx
return new THREE.Object3D()
})
await lm.loadModel('api/view?filename=cube.glb')
expect(capturedCtx!.standardMaterial).toBe(modelManager.standardMaterial)
expect(capturedCtx!.materialMode).toBe('wireframe')
})
it('suppresses alerts and modelLoadingEnd when a stale load throws', async () => {
const { lm, eventManager } = makeLoaderManager()
let rejectFirst!: (err: unknown) => void
const firstLoad = new Promise<THREE.Object3D>((_, r) => {
rejectFirst = r
})
meshLoad
.mockImplementationOnce(() => firstLoad)
.mockResolvedValueOnce(new THREE.Object3D())
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const firstPromise = lm.loadModel('api/view?filename=first.glb')
const secondPromise = lm.loadModel('api/view?filename=second.glb')
rejectFirst(new Error('stale failure'))
await Promise.all([firstPromise, secondPromise])
expect(addAlert).not.toHaveBeenCalled()
const endEmits = eventManager.emitEvent.mock.calls.filter(
(call: unknown[]) => call[0] === 'modelLoadingEnd'
)
expect(endEmits).toHaveLength(1)
consoleError.mockRestore()
})
})
})

View File

@@ -1,60 +1,49 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
// Use pre-bundled worker module (has all dependencies included)
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type * as THREE from 'three'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import { MeshModelAdapter } from './MeshModelAdapter'
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
import {
type EventManagerInterface,
type LoaderManagerInterface,
type ModelManagerInterface
} from './interfaces'
import { FastPLYLoader } from './loader/FastPLYLoader'
/**
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
* file extensions it owns; LoaderManager picks one by extension.
*/
function defaultAdapters(): ModelAdapter[] {
return [
new MeshModelAdapter(),
new PointCloudModelAdapter(),
new SplatModelAdapter()
]
}
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
plyLoader: PLYLoader
fastPlyLoader: FastPLYLoader
private modelManager: ModelManagerInterface
private eventManager: EventManagerInterface
private readonly modelManager: ModelManagerInterface
private readonly eventManager: EventManagerInterface
private readonly adapters: ModelAdapter[]
private currentLoadId: number = 0
private _currentAdapter: ModelAdapter | null = null
constructor(
modelManager: ModelManagerInterface,
eventManager: EventManagerInterface
eventManager: EventManagerInterface,
adapters?: readonly ModelAdapter[]
) {
this.modelManager = modelManager
this.eventManager = eventManager
this.adapters = adapters ? [...adapters] : defaultAdapters()
}
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader2Parallel()
// Set worker URL for Vite compatibility
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
this.plyLoader = new PLYLoader()
this.fastPlyLoader = new FastPLYLoader()
getCurrentAdapter(): ModelAdapter | null {
return this._currentAdapter
}
init(): void {}
@@ -68,6 +57,7 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager.emitEvent('modelLoadingStart', null)
this.modelManager.clearModel()
this._currentAdapter = null
this.modelManager.originalURL = url
@@ -80,12 +70,9 @@ export class LoaderManager implements LoaderManagerInterface {
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
if (filename) {
this.modelManager.originalFileName = filename.split('.')[0] || 'model'
} else {
this.modelManager.originalFileName = 'model'
}
this.modelManager.originalFileName = filename
? filename.split('.')[0] || 'model'
: 'model'
}
if (!fileExtension) {
@@ -93,19 +80,21 @@ export class LoaderManager implements LoaderManagerInterface {
return
}
const model = await this.loadModelInternal(url, fileExtension)
const result = await this.loadModelInternal(url, fileExtension)
if (loadId !== this.currentLoadId) {
return
}
if (model) {
await this.modelManager.setupModel(model)
if (result && result.model) {
this._currentAdapter = result.adapter
await this.modelManager.setupModel(result.model)
}
this.eventManager.emitEvent('modelLoadingEnd', null)
} catch (error) {
if (loadId === this.currentLoadId) {
this._currentAdapter = null
this.eventManager.emitEvent('modelLoadingEnd', null)
console.error('Error loading model:', error)
useToastStore().addAlert(t('toastMessages.errorLoadingModel'))
@@ -113,26 +102,50 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
private pickAdapter(extension: string): ModelAdapter | null {
const match = this.adapters.find((adapter) =>
adapter.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
}
return match
}
private createLoadContext(): ModelLoadContext {
const mm = this.modelManager
return {
setOriginalModel: (model) => mm.setOriginalModel(model),
registerOriginalMaterial: (mesh, material) =>
mm.originalMaterials.set(mesh, material),
get standardMaterial() {
return mm.standardMaterial
},
get materialMode() {
return mm.materialMode
}
}
}
private async loadModelInternal(
url: string,
fileExtension: string
): Promise<THREE.Object3D | null> {
let model: THREE.Object3D | null = null
): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> {
const params = new URLSearchParams(url.split('?')[1])
const filename = params.get('filename')
if (!filename) {
console.error('Missing filename in URL:', url)
return null
}
const loadRootFolder = params.get('type') === 'output' ? 'output' : 'input'
const subfolder = params.get('subfolder') ?? ''
const path =
'api/view?type=' +
loadRootFolder +
@@ -140,217 +153,10 @@ export class LoaderManager implements LoaderManagerInterface {
encodeURIComponent(subfolder) +
'&filename='
switch (fileExtension) {
case 'stl':
this.stlLoader.setPath(path)
const geometry = await this.stlLoader.loadAsync(filename)
this.modelManager.setOriginalModel(geometry)
geometry.computeVertexNormals()
const adapter = this.pickAdapter(fileExtension)
if (!adapter) return null
const mesh = new THREE.Mesh(
geometry,
this.modelManager.standardMaterial
)
const group = new THREE.Group()
group.add(mesh)
model = group
break
case 'fbx':
this.fbxLoader.setPath(path)
const fbxModel = await this.fbxLoader.loadAsync(filename)
this.modelManager.setOriginalModel(fbxModel)
model = fbxModel
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break
case 'obj':
if (this.modelManager.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)
const mtlFileName = filename.replace(/\.obj$/, '.mtl')
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
const objUrl = path + encodeURIComponent(filename)
model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
}
})
break
case 'gltf':
case 'glb':
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
this.modelManager.setOriginalModel(gltf)
model = gltf.scene
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break
case 'ply':
model = await this.loadPLY(path, filename)
break
case 'spz':
case 'splat':
case 'ksplat':
model = await this.loadSplat(path, filename)
break
}
return model
}
private async fetchModelData(path: string, filename: string) {
const route =
'/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}
private async loadSplat(
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await this.fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
this.modelManager.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
private async loadPLY(
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const plyEngine = useSettingStore().get('Comfy.Load3D.PLYEngine') as string
if (plyEngine === 'sparkjs') {
return this.loadSplat(path, filename)
}
// Use Three.js PLYLoader or FastPLYLoader for point cloud PLY files
const arrayBuffer = await this.fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
let plyGeometry: THREE.BufferGeometry
if (isASCII && plyEngine === 'fastply') {
plyGeometry = this.fastPlyLoader.parse(arrayBuffer)
} else {
this.plyLoader.setPath(path)
plyGeometry = this.plyLoader.parse(arrayBuffer)
}
this.modelManager.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
const materialMode = this.modelManager.materialMode
// Use Points rendering for pointCloud mode (better for point clouds)
if (materialMode === 'pointCloud') {
plyGeometry.computeBoundingSphere()
if (plyGeometry.boundingSphere) {
const center = plyGeometry.boundingSphere.center
const radius = plyGeometry.boundingSphere.radius
plyGeometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
plyGeometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const plyPoints = new THREE.Points(plyGeometry, pointMaterial)
this.modelManager.originalMaterials.set(
plyPoints as unknown as THREE.Mesh,
pointMaterial
)
const plyGroup = new THREE.Group()
plyGroup.add(plyPoints)
return plyGroup
}
// Use Mesh rendering for other modes
let plyMaterial: THREE.Material
if (hasVertexColors) {
plyMaterial = new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
} else {
plyMaterial = this.modelManager.standardMaterial.clone()
plyMaterial.side = THREE.DoubleSide
}
const plyMesh = new THREE.Mesh(plyGeometry, plyMaterial)
this.modelManager.originalMaterials.set(plyMesh, plyMaterial)
const plyGroup = new THREE.Group()
plyGroup.add(plyMesh)
return plyGroup
const model = await adapter.load(this.createLoadContext(), path, filename)
return { adapter, model }
}
}

View File

@@ -0,0 +1,302 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MeshModelAdapter } from './MeshModelAdapter'
import type { ModelLoadContext } from './ModelAdapter'
const stlLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<THREE.BufferGeometry>>()
}
const fbxLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<THREE.Object3D>>()
}
const gltfLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<{ scene: THREE.Object3D }>>()
}
const mtlLoaderStub = {
setPath: vi.fn(),
loadAsync: vi.fn<(filename: string) => Promise<{ preload: () => void }>>()
}
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
vi.mock('three/examples/jsm/loaders/STLLoader', () => ({
STLLoader: class {
setPath = stlLoaderStub.setPath
loadAsync = stlLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/FBXLoader', () => ({
FBXLoader: class {
setPath = fbxLoaderStub.setPath
loadAsync = fbxLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/GLTFLoader', () => ({
GLTFLoader: class {
setPath = gltfLoaderStub.setPath
loadAsync = gltfLoaderStub.loadAsync
}
}))
vi.mock('three/examples/jsm/loaders/MTLLoader', () => ({
MTLLoader: class {
setPath = mtlLoaderStub.setPath
loadAsync = mtlLoaderStub.loadAsync
}
}))
vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
addMaterialsFromMtlLoader: vi.fn().mockReturnValue([])
}
}))
vi.mock('wwobjloader2/bundle/worker/module?url', () => ({
default: 'mock-worker-url'
}))
function makeContext(
materialMode: ModelLoadContext['materialMode'] = 'original'
): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode
}
}
function makeFbxLikeGroup(): THREE.Group {
const group = new THREE.Group()
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
group.add(mesh)
return group
}
describe('MeshModelAdapter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('identity', () => {
it('identifies as a mesh adapter with full capabilities', () => {
const adapter = new MeshModelAdapter()
expect(adapter.kind).toBe('mesh')
expect(adapter.capabilities.fitToViewer).toBe(true)
expect(adapter.capabilities.requiresMaterialRebuild).toBe(false)
expect(adapter.capabilities.gizmoTransform).toBe(true)
expect(adapter.capabilities.lighting).toBe(true)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'normal',
'wireframe'
])
})
it('handles the expected mesh extensions', () => {
const adapter = new MeshModelAdapter()
expect([...adapter.extensions]).toEqual([
'stl',
'fbx',
'obj',
'gltf',
'glb'
])
})
})
describe('dispatch fallbacks', () => {
it('returns null when the filename extension belongs to another adapter', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'cloud.ply')
expect(result).toBeNull()
})
it('returns null for an unknown extension', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'data.xyz')
expect(result).toBeNull()
})
it('returns null for a filename without an extension', async () => {
const adapter = new MeshModelAdapter()
const result = await adapter.load(makeContext(), '/path/', 'noextension')
expect(result).toBeNull()
})
})
describe('STL loader path', () => {
it('loads STL geometry and wraps it in a Group with a Mesh child', async () => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
)
stlLoaderStub.loadAsync.mockResolvedValue(geometry)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'model.stl')
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)
})
})
describe('FBX loader path', () => {
it('loads an FBX model and registers its mesh materials', async () => {
const fbxModel = makeFbxLikeGroup()
fbxLoaderStub.loadAsync.mockResolvedValue(fbxModel)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'rig.fbx')
expect(fbxLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(fbxModel)
})
it('disables frustum culling on SkinnedMesh children', async () => {
const group = new THREE.Group()
const skinned = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
skinned.frustumCulled = true
group.add(skinned)
fbxLoaderStub.loadAsync.mockResolvedValue(group)
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'animated.fbx')
expect(skinned.frustumCulled).toBe(false)
})
})
describe('OBJ loader path', () => {
it('attempts the MTL sidecar in original material mode', async () => {
mtlLoaderStub.loadAsync.mockResolvedValue({ preload: vi.fn() })
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
await adapter.load(makeContext('original'), '/api/view/', 'cube.obj')
expect(mtlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(mtlLoaderStub.loadAsync).toHaveBeenCalledWith('cube.mtl')
expect(objLoaderStub.setMaterials).toHaveBeenCalled()
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
})
it('swallows MTL load errors and continues without materials', async () => {
mtlLoaderStub.loadAsync.mockRejectedValue(new Error('no mtl'))
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const result = await adapter.load(
makeContext('original'),
'/api/view/',
'cube.obj'
)
expect(result).toBeInstanceOf(THREE.Group)
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
})
it('skips the MTL attempt for non-original material modes', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
await adapter.load(makeContext('wireframe'), '/api/view/', 'cube.obj')
expect(mtlLoaderStub.loadAsync).not.toHaveBeenCalled()
expect(objLoaderStub.loadAsync).toHaveBeenCalledWith('/api/view/cube.obj')
})
it('registers materials for each mesh child', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'cube.obj')
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
})
describe('GLTF loader path', () => {
it('loads a .glb and returns the scene with vertex normals computed', async () => {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
const computeNormals = vi.spyOn(mesh.geometry, 'computeVertexNormals')
const scene = new THREE.Group()
scene.add(mesh)
const gltf = { scene }
gltfLoaderStub.loadAsync.mockResolvedValue(gltf)
const adapter = new MeshModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view/', 'scene.glb')
expect(gltfLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.glb')
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
expect(computeNormals).toHaveBeenCalled()
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
expect(result).toBe(scene)
})
it('also handles .gltf filenames', async () => {
gltfLoaderStub.loadAsync.mockResolvedValue({ scene: new THREE.Group() })
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'scene.gltf')
expect(gltfLoaderStub.loadAsync).toHaveBeenCalledWith('scene.gltf')
})
it('disables frustum culling on SkinnedMesh children inside the scene', async () => {
const scene = new THREE.Group()
const skinned = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial()
)
skinned.frustumCulled = true
scene.add(skinned)
gltfLoaderStub.loadAsync.mockResolvedValue({ scene })
const adapter = new MeshModelAdapter()
await adapter.load(makeContext(), '/api/view/', 'rigged.glb')
expect(skinned.frustumCulled).toBe(false)
})
})
})

View File

@@ -0,0 +1,155 @@
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
// Use pre-bundled worker module (has all dependencies included).
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds.
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
export class MeshModelAdapter implements ModelAdapter {
readonly kind = 'mesh' as const
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}
private readonly gltfLoader = new GLTFLoader()
private readonly objLoader: OBJLoader2Parallel
private readonly mtlLoader = new MTLLoader()
private readonly fbxLoader = new FBXLoader()
private readonly stlLoader = new STLLoader()
constructor() {
this.objLoader = new OBJLoader2Parallel()
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | 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
}
private async loadSTL(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.stlLoader.setPath(path)
const geometry = await this.stlLoader.loadAsync(filename)
ctx.setOriginalModel(geometry)
geometry.computeVertexNormals()
const mesh = new THREE.Mesh(geometry, ctx.standardMaterial)
const group = new THREE.Group()
group.add(mesh)
return group
}
private async loadFBX(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.fbxLoader.setPath(path)
const fbxModel = await this.fbxLoader.loadAsync(filename)
ctx.setOriginalModel(fbxModel)
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
ctx.registerOriginalMaterial(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
return fbxModel
}
private async loadOBJ(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)
const mtlFileName = filename.replace(/\.obj$/i, '.mtl')
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
const objUrl = path + encodeURIComponent(filename)
const model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
ctx.registerOriginalMaterial(child, child.material)
}
})
return model
}
private async loadGLTF(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
ctx.setOriginalModel(gltf)
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
ctx.registerOriginalMaterial(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
return gltf.scene
}
}

View File

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { DEFAULT_MODEL_CAPABILITIES, fetchModelData } from './ModelAdapter'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
}
}))
describe('DEFAULT_MODEL_CAPABILITIES', () => {
it('enables fit-to-viewer / gizmo / lighting / export by default', () => {
expect(DEFAULT_MODEL_CAPABILITIES.fitToViewer).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.requiresMaterialRebuild).toBe(false)
expect(DEFAULT_MODEL_CAPABILITIES.gizmoTransform).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.lighting).toBe(true)
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
'original',
'normal',
'wireframe'
])
})
})
describe('fetchModelData', () => {
const mockFetchApi = vi.mocked(api.fetchApi)
beforeEach(() => {
mockFetchApi.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the arrayBuffer on a successful response', async () => {
const buf = new ArrayBuffer(8)
mockFetchApi.mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: vi.fn().mockResolvedValue(buf)
} as unknown as Response)
const result = await fetchModelData('api/view?...&filename=', 'model.glb')
expect(result).toBe(buf)
})
it('throws with status code when the response is not ok', async () => {
mockFetchApi.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await expect(
fetchModelData('api/view?type=input&subfolder=&filename=', 'missing.glb')
).rejects.toThrow('Failed to fetch model: 404')
})
it('strips the leading api/ prefix and encodes the filename', async () => {
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
} as unknown as Response)
await fetchModelData(
'api/view?type=input&subfolder=&filename=',
'a b c.ply'
)
expect(mockFetchApi).toHaveBeenCalledWith(
'/view?type=input&subfolder=&filename=a%20b%20c.ply'
)
})
it('prepends a single slash when the path has no api/ prefix', async () => {
mockFetchApi.mockResolvedValue({
ok: true,
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0))
} as unknown as Response)
await fetchModelData('custom?filename=', 'scene.splat')
expect(mockFetchApi).toHaveBeenCalledWith('/custom?filename=scene.splat')
})
})

View File

@@ -0,0 +1,88 @@
import type * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { api } from '@/scripts/api'
import type { MaterialMode } from './interfaces'
export interface ModelLoadContext {
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void
registerOriginalMaterial(
mesh: THREE.Mesh,
material: THREE.Material | THREE.Material[]
): void
readonly standardMaterial: THREE.MeshStandardMaterial
readonly materialMode: MaterialMode
}
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**
* Whether auto-normalize/centering on load and the explicit fit-to-viewer
* action should run. Splats render self-sized and are placed at a fixed
* camera distance instead.
*/
fitToViewer: boolean
/**
* Whether a material mode change must rebuild the scene object instead of
* traversing the existing mesh tree. True for point-cloud PLY (Mesh <->
* Points swap); false for regular meshes and self-rendering splats.
*/
requiresMaterialRebuild: boolean
/**
* Whether the gizmo transform UI (translate/rotate/scale) should be
* exposed for this model type. False for adapters whose already-normalized
* output makes user transforms meaningless (PLY point cloud).
*/
gizmoTransform: boolean
/** Whether scene-lighting controls apply. False for self-lit formats. */
lighting: boolean
/** Whether the model can be exported (GLB/OBJ/STL). */
exportable: boolean
/**
* Material modes offered in the UI for this format. An empty array hides
* the material-mode dropdown entirely.
*/
materialModes: readonly MaterialMode[]
/**
* World-space target size along the largest dimension after
* fit-to-viewer normalization. Controls how large the model ends up
* relative to the 20-unit scene grid; splats use a larger value so they
* don't shrink to a quarter of the floor.
*/
fitTargetSize: number
}
export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
fitToViewer: true,
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: true,
exportable: true,
materialModes: ['original', 'normal', 'wireframe'],
fitTargetSize: 5
}
export interface ModelAdapter {
readonly kind: ModelAdapterKind
readonly extensions: readonly string[]
readonly capabilities: ModelAdapterCapabilities
load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null>
}
export async function fetchModelData(
path: string,
filename: string
): Promise<ArrayBuffer> {
const route = '/' + path.replace(/^api\//, '') + encodeURIComponent(filename)
const response = await api.fetchApi(route)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.status}`)
}
return response.arrayBuffer()
}

View File

@@ -0,0 +1,116 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelLoadContext } from './ModelAdapter'
import * as ModelAdapterModule from './ModelAdapter'
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
const mockSettingGet = vi.fn<(key: string) => unknown>()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: mockSettingGet })
}))
vi.mock('@/scripts/metadata/ply', () => ({
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
}))
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
PLYLoader: class {
setPath = vi.fn()
parse = vi.fn(() => makePLYGeometry(false))
}
}))
vi.mock('./loader/FastPLYLoader', () => ({
FastPLYLoader: class {
parse = vi.fn(() => makePLYGeometry(false))
}
}))
function makePLYGeometry(withColors: 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) {
geometry.setAttribute(
'color',
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
)
}
return geometry
}
function makeContext(
materialMode: ModelLoadContext['materialMode'] = 'original'
): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode
}
}
describe('PointCloudModelAdapter', () => {
beforeEach(() => {
mockSettingGet.mockReset()
})
describe('identity', () => {
it('handles the ply extension', () => {
const adapter = new PointCloudModelAdapter()
expect([...adapter.extensions]).toEqual(['ply'])
})
it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => {
const adapter = new PointCloudModelAdapter()
expect(adapter.kind).toBe('pointCloud')
expect(adapter.capabilities.fitToViewer).toBe(false)
expect(adapter.capabilities.requiresMaterialRebuild).toBe(true)
expect(adapter.capabilities.gizmoTransform).toBe(false)
expect(adapter.capabilities.lighting).toBe(true)
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'pointCloud',
'normal',
'wireframe'
])
})
})
describe('load', () => {
beforeEach(() => {
mockSettingGet.mockReturnValue('three')
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
new ArrayBuffer(0)
)
})
it('returns a Group containing a Mesh for non-pointCloud modes', async () => {
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('original')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(child).toBeInstanceOf(THREE.Mesh)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
})
it('returns a Group containing Points when materialMode is pointCloud', async () => {
const adapter = new PointCloudModelAdapter()
const ctx = makeContext('pointCloud')
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
expect(result).toBeInstanceOf(THREE.Group)
const child = result!.children[0]
expect(child).toBeInstanceOf(THREE.Points)
})
})
})

View File

@@ -0,0 +1,120 @@
import * as THREE from 'three'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isPLYAsciiFormat } from '@/scripts/metadata/ply'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
import { FastPLYLoader } from './loader/FastPLYLoader'
export function getPLYEngine(): string {
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
}
export class PointCloudModelAdapter implements ModelAdapter {
readonly kind = 'pointCloud' as const
readonly extensions = ['ply'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
requiresMaterialRebuild: true,
gizmoTransform: false,
lighting: true,
exportable: true,
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
fitTargetSize: 5
}
private readonly plyLoader = new PLYLoader()
private readonly fastPlyLoader = new FastPLYLoader()
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D | null> {
const arrayBuffer = await fetchModelData(path, filename)
const isASCII = isPLYAsciiFormat(arrayBuffer)
const plyGeometry =
isASCII && getPLYEngine() === 'fastply'
? this.fastPlyLoader.parse(arrayBuffer)
: this.plyLoader.parse(arrayBuffer)
ctx.setOriginalModel(plyGeometry)
plyGeometry.computeVertexNormals()
const hasVertexColors = plyGeometry.attributes.color !== undefined
if (ctx.materialMode === 'pointCloud') {
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
}
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
}
}
function buildPointsGroup(
ctx: ModelLoadContext,
geometry: THREE.BufferGeometry,
hasVertexColors: boolean
): THREE.Group {
geometry.computeBoundingSphere()
if (geometry.boundingSphere) {
const { center, radius } = geometry.boundingSphere
geometry.translate(-center.x, -center.y, -center.z)
if (radius > 0) {
const scale = 1.0 / radius
geometry.scale(scale, scale, scale)
}
}
const pointMaterial = hasVertexColors
? new THREE.PointsMaterial({
size: 0.005,
vertexColors: true,
sizeAttenuation: true
})
: new THREE.PointsMaterial({
size: 0.005,
color: 0xcccccc,
sizeAttenuation: true
})
const points = new THREE.Points(geometry, pointMaterial)
ctx.registerOriginalMaterial(points as unknown as THREE.Mesh, pointMaterial)
const group = new THREE.Group()
group.add(points)
return group
}
function buildMeshGroup(
ctx: ModelLoadContext,
geometry: THREE.BufferGeometry,
hasVertexColors: boolean
): THREE.Group {
const material = hasVertexColors
? new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.0,
roughness: 0.5,
side: THREE.DoubleSide
})
: ctx.standardMaterial.clone()
if (!hasVertexColors && material instanceof THREE.MeshStandardMaterial) {
material.side = THREE.DoubleSide
}
const mesh = new THREE.Mesh(geometry, material)
ctx.registerOriginalMaterial(mesh, material)
const group = new THREE.Group()
group.add(mesh)
return group
}

View File

@@ -0,0 +1,82 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelLoadContext } from './ModelAdapter'
import * as ModelAdapterModule from './ModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'
const { splatMeshCtor } = vi.hoisted(() => ({
splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>()
}))
vi.mock('@sparkjsdev/spark', async () => {
const three = await import('three')
return {
SplatMesh: class extends three.Object3D {
constructor(opts: { fileBytes: ArrayBuffer }) {
super()
splatMeshCtor(opts)
}
}
}
})
function makeContext(): ModelLoadContext {
return {
setOriginalModel: vi.fn(),
registerOriginalMaterial: vi.fn(),
standardMaterial: new THREE.MeshStandardMaterial(),
materialMode: 'original'
}
}
describe('SplatModelAdapter', () => {
beforeEach(() => {
splatMeshCtor.mockReset()
})
it('exposes splat capabilities on the adapter', () => {
const adapter = new SplatModelAdapter()
expect(adapter.kind).toBe('splat')
expect(adapter.capabilities.lighting).toBe(false)
expect(adapter.capabilities.exportable).toBe(false)
expect([...adapter.capabilities.materialModes]).toEqual([])
})
it('handles the Gaussian splat extensions', () => {
const adapter = new SplatModelAdapter()
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
})
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
const buf = new ArrayBuffer(128)
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(buf)
const adapter = new SplatModelAdapter()
const ctx = makeContext()
const result = await adapter.load(ctx, '/api/view?', 'scene.splat')
expect(ModelAdapterModule.fetchModelData).toHaveBeenCalledWith(
'/api/view?',
'scene.splat'
)
expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf })
expect(result).toBeInstanceOf(THREE.Group)
expect(result.children).toHaveLength(1)
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
})
it('propagates fetch errors', async () => {
vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
new Error('Failed to fetch model: 500')
)
const adapter = new SplatModelAdapter()
await expect(
adapter.load(makeContext(), '/api/view?', 'scene.splat')
).rejects.toThrow('Failed to fetch model: 500')
})
})

View File

@@ -0,0 +1,38 @@
import { SplatMesh } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { fetchModelData } from './ModelAdapter'
import type {
ModelAdapter,
ModelAdapterCapabilities,
ModelLoadContext
} from './ModelAdapter'
export class SplatModelAdapter implements ModelAdapter {
readonly kind = 'splat' as const
readonly extensions = ['spz', 'splat', 'ksplat'] as const
readonly capabilities: ModelAdapterCapabilities = {
fitToViewer: false,
requiresMaterialRebuild: false,
gizmoTransform: false,
lighting: false,
exportable: false,
materialModes: [],
fitTargetSize: 5
}
async load(
ctx: ModelLoadContext,
path: string,
filename: string
): Promise<THREE.Object3D> {
const arrayBuffer = await fetchModelData(path, filename)
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
ctx.setOriginalModel(splatMesh)
const splatGroup = new THREE.Group()
splatGroup.add(splatMesh)
return splatGroup
}
}

View File

@@ -3,11 +3,7 @@
import type * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import type { OBJLoader2Parallel } from 'wwobjloader2'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
export type MaterialMode =
| 'original'
@@ -203,12 +199,6 @@ export interface ModelManagerInterface {
}
export interface LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
init(): void
dispose(): void
loadModel(url: string, originalFileName?: string): Promise<void>

View File

@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
function rightMouse(type: string, x: number, y: number, buttons = 2) {
const event = new MouseEvent(type, {
button: 2,
buttons,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true
})
return event
}
describe('attachContextMenuGuard', () => {
let target: HTMLElement
let onMenu: ReturnType<typeof vi.fn<(event: MouseEvent) => void>>
let dispose: () => void
beforeEach(() => {
target = document.createElement('div')
document.body.appendChild(target)
onMenu = vi.fn<(event: MouseEvent) => void>()
})
afterEach(() => {
dispose?.()
target.remove()
})
it('invokes onMenu for a right-click without drag movement', () => {
dispose = attachContextMenuGuard(target, onMenu)
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
expect(onMenu).toHaveBeenCalledOnce()
})
it('preventDefault is called on the contextmenu event when menu fires', () => {
dispose = attachContextMenuGuard(target, onMenu)
target.dispatchEvent(rightMouse('mousedown', 0, 0))
const contextEvent = rightMouse('contextmenu', 0, 0)
target.dispatchEvent(contextEvent)
expect(contextEvent.defaultPrevented).toBe(true)
})
it('suppresses onMenu when the mouse moved past the drag threshold', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 120, 120))
target.dispatchEvent(rightMouse('contextmenu', 120, 120))
expect(onMenu).not.toHaveBeenCalled()
})
it('still fires onMenu when the mouse moved within the drag threshold', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 10 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 103, 104))
target.dispatchEvent(rightMouse('contextmenu', 103, 104))
expect(onMenu).toHaveBeenCalledOnce()
})
it('detects a drag from start to contextmenu even without mousemove events', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
expect(onMenu).not.toHaveBeenCalled()
})
it('resets drag state between right-clicks', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 200, 200))
target.dispatchEvent(rightMouse('contextmenu', 200, 200))
expect(onMenu).not.toHaveBeenCalled()
target.dispatchEvent(rightMouse('mousedown', 50, 50))
target.dispatchEvent(rightMouse('contextmenu', 50, 50))
expect(onMenu).toHaveBeenCalledOnce()
})
it('ignores onMenu when isDisabled returns true', () => {
let disabled = true
dispose = attachContextMenuGuard(target, onMenu, {
isDisabled: () => disabled
})
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).not.toHaveBeenCalled()
disabled = false
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).toHaveBeenCalledOnce()
})
it('stops listening after dispose', () => {
dispose = attachContextMenuGuard(target, onMenu)
dispose()
target.dispatchEvent(rightMouse('mousedown', 10, 10))
target.dispatchEvent(rightMouse('contextmenu', 10, 10))
expect(onMenu).not.toHaveBeenCalled()
})
it('ignores mousemove events without the right button held', () => {
dispose = attachContextMenuGuard(target, onMenu, { dragThreshold: 5 })
target.dispatchEvent(rightMouse('mousedown', 100, 100))
target.dispatchEvent(rightMouse('mousemove', 200, 200, 0))
target.dispatchEvent(rightMouse('contextmenu', 100, 100))
expect(onMenu).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,72 @@
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
type ContextMenuGuardOptions = {
isDisabled?: () => boolean
dragThreshold?: number
}
export function attachContextMenuGuard(
target: HTMLElement,
onMenu: (event: MouseEvent) => void,
{ isDisabled = () => false, dragThreshold = 5 }: ContextMenuGuardOptions = {}
): () => void {
const abort = new AbortController()
const { signal } = abort
let start = { x: 0, y: 0 }
let moved = false
target.addEventListener(
'mousedown',
(e) => {
if (e.button === 2) {
start = { x: e.clientX, y: e.clientY }
moved = false
}
},
{ signal }
)
target.addEventListener(
'mousemove',
(e) => {
if (
e.buttons === 2 &&
exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
dragThreshold
)
) {
moved = true
}
},
{ signal }
)
target.addEventListener(
'contextmenu',
(e) => {
if (isDisabled()) return
const wasDragging =
moved ||
exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
dragThreshold
)
moved = false
if (wasDragging) return
e.preventDefault()
e.stopPropagation()
onMenu(e)
},
{ signal }
)
return () => abort.abort()
}

View File

@@ -0,0 +1,82 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionService } from '@/services/extensionService'
import type { AboutPageBadge, TopbarBadge } from '@/types/comfy'
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
const prNumber = __CI_PR_NUMBER__
const author = __CI_PR_AUTHOR__
const commit = __COMFYUI_FRONTEND_COMMIT__
const commitShort = commit ? commit.slice(0, 8) : ''
const settingStore = useSettingStore()
const apiNodesEnabled = settingStore.get('Comfy.NodeBadge.ShowApiPricing')
const backendUrl = localStorage.getItem('comfyui-preview-backend-url') ?? '—'
const tooltipLines = [
author ? `@${author}` : null,
commitShort ? commitShort : null,
`${t('prPreview.badge.tooltipBackendLabel')}${backendUrl}`,
apiNodesEnabled
? t('prPreview.badge.tooltipCloudApiNote')
: t('prPreview.badge.tooltipCloudApiDisabled')
]
.filter(Boolean)
.join(' · ')
const popoverLinks = [
{ label: `PR #${prNumber}`, url: `${REPO}/pull/${prNumber}` },
...(author
? [{ label: `@${author}`, url: `https://github.com/${author}` }]
: []),
...(commitShort
? [{ label: commitShort, url: `${REPO}/commit/${commit}` }]
: []),
{ label: t('prPreview.badge.configureBackend'), url: '/connect' }
]
const badgeText = commitShort ? `#${prNumber} · ${commitShort}` : `#${prNumber}`
const topbarBadges: TopbarBadge[] = [
{
label: t('prPreview.badge.label'),
text: badgeText,
variant: 'warning',
tooltip: tooltipLines,
popoverLinks
}
]
const aboutPageBadges: AboutPageBadge[] = [
{
label: `PR #${prNumber}`,
url: `${REPO}/pull/${prNumber}`,
icon: 'pi pi-github'
},
...(author
? [
{
label: `@${author}`,
url: `https://github.com/${author}`,
icon: 'pi pi-user'
}
]
: []),
...(commitShort
? [
{
label: commitShort,
url: `${REPO}/commit/${commit}`,
icon: 'pi pi-code'
}
]
: [])
]
useExtensionService().registerExtension({
name: 'Comfy.PrPreview.Badges',
topbarBadges,
aboutPageBadges
})

View File

@@ -3798,5 +3798,72 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"connectionPanel": {
"title": "ComfyUI Frontend Preview",
"subtitle": "Connect to a running ComfyUI backend to use this preview.",
"previewWarningTitle": "⚠ This is a preview build of an in-flight pull request.",
"previewWarningBody": "The UI may change rapidly as the branch is pushed and is under heavy development. Use it for testing and review only — never rely on any *.comfy-ui.pages.dev URL for production work.",
"previewProvenance": "Built from {pr} ({commit}) by {author}.",
"previewUnknownAuthor": "an unknown author",
"previewTrustWarning": "Do not connect a ComfyUI instance you care about unless you trust the author of this PR — a malicious frontend can read and modify any workflow, model, or output on the connected backend.",
"backendUrl": "Backend URL",
"apiKey": "Comfy API Key",
"apiKeyOptional": "(optional)",
"apiKeyPlaceholder": "sk-...",
"apiKeyHint": "Only needed for cloud-API nodes (e.g. Flux, Kling). Saved to your browser.",
"apiKeyTestOk": "API key is valid.",
"apiKeyTestError": "Invalid or expired API key.",
"apiKeyDisabledNotice": "The connected backend was started with --disable-api-nodes; cloud-API nodes are unavailable.",
"test": "Test",
"http": "HTTP",
"ws": "WS",
"status": "Connection Status",
"connected": "Connected — backend is reachable.",
"backendCloud": "Backend cloud API:",
"cloudMismatch": "⚠ Cloud environment mismatch — this preview signs in via {frontend}, so tokens won't be accepted by the backend. Restart the backend with --comfy-api-base={frontend} (or use a frontend build that targets the backend's environment).",
"getApiKeyLink": "→ Generate an API key for this cloud",
"connectAndGo": "Connect & Open ComfyUI",
"quickStart": "Quick Start with Comfy CLI",
"quickStartDescription": "The fastest way to get ComfyUI running locally. No existing Python install required — uv handles it for you.",
"step1InstallUv": "1. Install uv (macOS/Linux, then Windows):",
"uvNote": "uv is a fast Python package manager that auto-installs Python itself, so you don't need Python preinstalled. After install, restart your terminal.",
"step2InstallComfyui": "2. Install comfy-cli and ComfyUI:",
"managerIncludedNote": "This also installs ComfyUI-Manager by default — it makes downloading missing models and custom nodes one-click, so workflows from others just work.",
"managerTitle": "Why ComfyUI-Manager?",
"managerDescription": "ComfyUI-Manager is bundled with comfy install. It auto-detects missing custom nodes and models referenced by any workflow you load, then installs them for you in one click — no more hunting GitHub repos or Hugging Face links by hand.",
"managerLearnMore": "Learn more about ComfyUI-Manager →",
"step3Launch": "3. Launch with CORS enabled:",
"altManualSetup": "Alternative: I already have Python installed",
"altPipDescription": "If you already have Python 3.10+ and pip available, you can install comfy-cli directly:",
"altPipNote": "Note: older Python versions (<3.10) may fail to install some comfy-cli dependencies.",
"altManagerDescription": "If you cloned ComfyUI manually, also install ComfyUI-Manager into custom_nodes/:",
"guideDescription": "If you already have ComfyUI cloned, start it with CORS enabled from the repo root:",
"corsNote": "The --enable-cors-header flag allows this preview page to communicate with your local backend.",
"corsOriginNote": "The exact origin is pre-filled so ComfyUI can allow requests from this specific preview URL. Using * would block requests that include credentials.",
"localAccess": "Local Network Access",
"localAccessDescription": "Your browser may prompt for permission to access local network devices. Allow it so this page can reach your local ComfyUI instance.",
"localAccessListenDescription": "To connect from another device on the same network (e.g. a phone or second computer), pass --listen so ComfyUI binds to all interfaces:",
"localAccessListenNote": "Then enter your machine's LAN IP (e.g. http://192.168.1.x:8188) in the backend URL field above.",
"source": "Source",
"errorUnreachable": "Backend is unreachable. Ensure ComfyUI is running with CORS enabled.",
"errorHttpFailed": "HTTP connection failed. Check the URL and CORS settings.",
"errorWsFailed": "WebSocket connection failed. HTTP works — check firewall or proxy settings.",
"buildPr": "PR #{prNumber}",
"buildVersion": "v{version}",
"tooltipVersion": "Version: {version}",
"tooltipCommit": "Commit: {commit}",
"tooltipBranch": "Branch: {branch}",
"tooltipRunId": "Run ID: {runId}",
"tooltipJobId": "Job ID: {jobId}"
},
"prPreview": {
"badge": {
"label": "PR",
"tooltipBackendLabel": "Backend: ",
"tooltipCloudApiNote": "Cloud API: see Settings → About",
"tooltipCloudApiDisabled": "Cloud API: disabled",
"configureBackend": "Configure backend →"
}
}
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isBackendReachable } from './backendReachable'
const STORAGE_KEY = 'comfyui-preview-backend-url'
const mockLocalStorage = vi.hoisted(() => {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) delete store[key]
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
_store: store
}
})
vi.stubGlobal('localStorage', mockLocalStorage)
function mockFetchOnce(impl: () => Promise<Response> | Response) {
vi.stubGlobal('fetch', vi.fn(impl))
}
describe('isBackendReachable', () => {
beforeEach(() => {
mockLocalStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.stubGlobal('localStorage', mockLocalStorage)
})
it('returns true when system_stats responds with a system field', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(
() =>
new Response(JSON.stringify({ system: { os: 'darwin' } }), {
status: 200
})
)
expect(await isBackendReachable()).toBe(true)
})
it('returns false when response is not ok', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => new Response('Not Found', { status: 404 }))
expect(await isBackendReachable()).toBe(false)
})
it('returns false when response is HTML (no system field)', async () => {
// Simulates a Cloudflare-style SPA fallback returning index.html
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => new Response(JSON.stringify({}), { status: 200 }))
expect(await isBackendReachable()).toBe(false)
})
it('returns false when fetch rejects (network error / CORS / aborted)', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => Promise.reject(new Error('network')))
expect(await isBackendReachable()).toBe(false)
})
it('strips trailing slashes from the configured backend URL', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188///')
const fetchSpy = vi.fn(
() =>
new Response(JSON.stringify({ system: { os: 'linux' } }), {
status: 200
})
)
vi.stubGlobal('fetch', fetchSpy)
await isBackendReachable()
expect(fetchSpy).toHaveBeenCalledWith(
'http://127.0.0.1:8188/api/system_stats',
expect.any(Object)
)
})
it('falls back to same-origin when no backend URL is configured', async () => {
const fetchSpy = vi.fn(
() =>
new Response(JSON.stringify({ system: { os: 'linux' } }), {
status: 200
})
)
vi.stubGlobal('fetch', fetchSpy)
await isBackendReachable()
expect(fetchSpy).toHaveBeenCalledWith(
'/api/system_stats',
expect.any(Object)
)
})
})

View File

@@ -0,0 +1,47 @@
/**
* Probe the configured ComfyUI backend (local or remote-via-localStorage)
* to confirm it serves the expected `/api/system_stats` shape. Used by the
* router to decide whether to enter GraphView or redirect to /connect.
*/
const BACKEND_URL_KEY = 'comfyui-preview-backend-url'
const PROBE_TIMEOUT_MS = 3000
function resolveProbeBase(): string {
const stored = localStorage.getItem(BACKEND_URL_KEY)
if (stored) {
try {
// Only treat the stored value as a backend override when it's a
// well-formed absolute URL — otherwise fall through to same-origin.
const url = new URL(stored)
return url.origin + url.pathname.replace(/\/+$/, '')
} catch {
// Ignore malformed entries; same-origin probe is safer than a
// relative URL that misses the router's subpath base.
}
}
// Mirror ComfyApi's same-origin base so subpath deployments probe the
// backend that would actually serve the app.
if (typeof window === 'undefined') return ''
return window.location.pathname.split('/').slice(0, -1).join('/')
}
export async function isBackendReachable(): Promise<boolean> {
const apiBase = resolveProbeBase()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
const res = await fetch(`${apiBase}/api/system_stats`, {
signal: controller.signal
})
if (!res.ok) return false
const body = (await res.json()) as { system?: unknown }
return !!body.system
} catch {
return false
} finally {
clearTimeout(timeout)
}
}

View File

@@ -0,0 +1,24 @@
const COMFY_API_BASE_FLAG = '--comfy-api-base'
const DEFAULT_CLOUD_API_BASE = 'https://api.comfy.org'
type SystemInfo = { argv?: string[]; comfy_api_base?: string }
function parseArgvApiBase(argv: string[] | undefined): string | undefined {
if (!argv) return undefined
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === COMFY_API_BASE_FLAG && i + 1 < argv.length) return argv[i + 1]
if (a.startsWith(`${COMFY_API_BASE_FLAG}=`))
return a.slice(COMFY_API_BASE_FLAG.length + 1)
}
return undefined
}
export function resolveBackendCloudBase(
system: SystemInfo | undefined
): string {
const explicit = system?.comfy_api_base
if (explicit) return explicit.replace(/\/+$/, '')
const fromArgv = parseArgvApiBase(system?.argv)
return (fromArgv ?? DEFAULT_CLOUD_API_BASE).replace(/\/+$/, '')
}

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -11,6 +11,27 @@ import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
import { AssetKindKey } from './types'
import type { FormDropdownMenuItemProps } from './types'
const mockFindServerPreviewUrl = vi.hoisted(() => vi.fn())
const mockIsAssetPreviewSupported = vi.hoisted(() => vi.fn(() => true))
const intersectionCallbacks = vi.hoisted(
() => [] as Array<(entries: Array<{ isIntersecting: boolean }>) => void>
)
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
findServerPreviewUrl: (name: string) => mockFindServerPreviewUrl(name),
isAssetPreviewSupported: () => mockIsAssetPreviewSupported()
}))
vi.mock('@vueuse/core', () => ({
useIntersectionObserver: (
_ref: unknown,
cb: (entries: Array<{ isIntersecting: boolean }>) => void
) => {
intersectionCallbacks.push(cb)
return { stop: vi.fn() }
}
}))
const selectedLabel = 'Selected'
const i18n = createI18n({
@@ -48,7 +69,23 @@ function renderItem(
})
}
function fireIntersection(isIntersecting = true) {
for (const cb of intersectionCallbacks) {
cb([{ isIntersecting }])
}
}
async function flushPromises() {
await new Promise((resolve) => setTimeout(resolve, 0))
}
describe('FormDropdownMenuItem', () => {
beforeEach(() => {
intersectionCallbacks.length = 0
mockFindServerPreviewUrl.mockReset()
mockIsAssetPreviewSupported.mockReset().mockReturnValue(true)
})
describe('Label and name', () => {
it('renders name when no label is provided', () => {
renderItem({ name: 'alpha' })
@@ -89,6 +126,85 @@ describe('FormDropdownMenuItem', () => {
)
expect(screen.queryByRole('img', { name: 'item_name' })).toBeNull()
})
it('does not look up mesh preview when kind is image', async () => {
renderItem({ previewUrl: '/preview.png' }, { assetKind: 'image' })
fireIntersection(true)
await flushPromises()
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
})
describe('Mesh thumbnail resolution', () => {
it('shows 3D placeholder icon when mesh preview is unresolved', () => {
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
expect(screen.getByTestId('dropdown-item-mesh-placeholder')).toBeTruthy()
})
it('looks up preview with basename after intersection fires', async () => {
mockFindServerPreviewUrl.mockResolvedValue('/api/view?preview=1')
renderItem({ name: '3d/sub/model.glb' }, { assetKind: 'mesh' })
fireIntersection(true)
await flushPromises()
expect(mockFindServerPreviewUrl).toHaveBeenCalledWith('model.glb')
})
it('strips [output] suffix before taking basename', async () => {
mockFindServerPreviewUrl.mockResolvedValue(null)
renderItem({ name: 'mesh/scene.glb [output]' }, { assetKind: 'mesh' })
fireIntersection(true)
await flushPromises()
expect(mockFindServerPreviewUrl).toHaveBeenCalledWith('scene.glb')
})
it('renders resolved URL in img once findServerPreviewUrl returns', async () => {
mockFindServerPreviewUrl.mockResolvedValue('/api/preview/resolved.png')
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
fireIntersection(true)
const img = (await screen.findByAltText(
'3d/model.glb'
)) as HTMLImageElement
expect(img.getAttribute('src')).toBe('/api/preview/resolved.png')
})
it('skips lookup when asset preview is unsupported', async () => {
mockIsAssetPreviewSupported.mockReturnValue(false)
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
fireIntersection(true)
await flushPromises()
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('only looks up once for repeated intersection events', async () => {
mockFindServerPreviewUrl.mockResolvedValue(null)
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
fireIntersection(true)
fireIntersection(true)
fireIntersection(true)
await flushPromises()
expect(mockFindServerPreviewUrl).toHaveBeenCalledTimes(1)
})
it('does not look up when not yet intersecting', async () => {
renderItem({ name: '3d/model.glb' }, { assetKind: 'mesh' })
fireIntersection(false)
await flushPromises()
expect(mockFindServerPreviewUrl).not.toHaveBeenCalled()
})
it('ignores the previewUrl prop for mesh kind', async () => {
mockFindServerPreviewUrl.mockResolvedValue(null)
renderItem(
{
name: '3d/model.glb',
previewUrl: '/api/view?filename=model.glb&type=input'
},
{ assetKind: 'mesh' }
)
fireIntersection(true)
await flushPromises()
expect(screen.queryByAltText('3d/model.glb')).toBeNull()
})
})
describe('Events', () => {

View File

@@ -1,9 +1,15 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import {
findServerPreviewUrl,
isAssetPreviewSupported
} from '@/platform/assets/utils/assetPreviewUtil'
import { AssetKindKey } from './types'
import type { FormDropdownMenuItemProps } from './types'
@@ -21,6 +27,42 @@ const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isMesh = computed(() => assetKind?.value === 'mesh')
const mediaContainerRef = ref<HTMLElement>()
const resolvedMeshPreview = ref<string | null>(null)
const meshPreviewAttempted = ref(false)
function toLookupName(name: string): string {
const stripped = name.replace(/ \[output\]$/, '')
const slash = stripped.lastIndexOf('/')
return slash === -1 ? stripped : stripped.substring(slash + 1)
}
async function resolveMeshPreview() {
if (!isAssetPreviewSupported()) return
const url = await findServerPreviewUrl(toLookupName(props.name))
if (url) resolvedMeshPreview.value = url
}
useIntersectionObserver(mediaContainerRef, ([entry]) => {
if (!entry?.isIntersecting) return
if (!isMesh.value || meshPreviewAttempted.value) return
meshPreviewAttempted.value = true
void resolveMeshPreview()
})
watch(
() => props.name,
() => {
meshPreviewAttempted.value = false
resolvedMeshPreview.value = null
}
)
const displayedPreviewUrl = computed(() =>
isMesh.value ? resolvedMeshPreview.value : props.previewUrl
)
function handleClick() {
emit('click', props.index)
@@ -68,6 +110,7 @@ function handleVideoLoad(event: Event) {
<!-- Image -->
<div
v-if="layout !== 'list-small'"
ref="mediaContainerRef"
:class="
cn(
'relative',
@@ -106,15 +149,23 @@ function handleVideoLoad(event: Event) {
@loadeddata="handleVideoLoad"
/>
<img
v-else-if="previewUrl"
:src="previewUrl"
v-else-if="displayedPreviewUrl"
:src="displayedPreviewUrl"
:alt="name"
draggable="false"
class="size-full object-cover"
@load="handleImageLoad"
/>
<div
v-else-if="isMesh"
data-testid="dropdown-item-mesh-placeholder"
class="flex size-full items-center justify-center bg-modal-card-placeholder-background"
>
<i class="icon-[lucide--box] text-3xl text-muted-foreground" />
</div>
<div
v-else
data-testid="dropdown-item-media-placeholder"
class="size-full bg-linear-to-tr from-blue-400 via-teal-500 to-green-400"
/>
</div>

View File

@@ -241,6 +241,59 @@ describe('useWidgetSelectItems', () => {
})
})
describe('mesh preview URL handling', () => {
it('leaves input preview_url empty for mesh kind', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['3d/model.glb', 'other.fbx'],
modelValue: ref('3d/model.glb'),
assetKind: () => 'mesh'
})
)
expect(dropdownItems.value).toHaveLength(2)
expect(dropdownItems.value[0].preview_url).toBe('')
expect(dropdownItems.value[1].preview_url).toBe('')
})
it('leaves output preview_url empty for mesh kind even when asset has one', async () => {
mockMediaAssets.media.value = [
{
id: 'asset-mesh-1',
name: 'scene.glb',
preview_url: '/api/view?filename=scene.glb&type=output',
tags: ['output']
}
]
const { dropdownItems, filterSelected } = useWidgetSelectItems(
createDefaultOptions({
values: () => [],
modelValue: ref(undefined),
assetKind: () => 'mesh'
})
)
filterSelected.value = 'outputs'
await nextTick()
expect(dropdownItems.value).toHaveLength(1)
expect(dropdownItems.value[0].preview_url).toBe('')
})
it('still uses getMediaUrl for image kind inputs', () => {
const { dropdownItems } = useWidgetSelectItems(
createDefaultOptions({
values: () => ['img_001.png'],
modelValue: ref('img_001.png'),
assetKind: () => 'image'
})
)
expect(dropdownItems.value[0].preview_url).toContain(
'filename=img_001.png'
)
expect(dropdownItems.value[0].preview_url).toContain('type=input')
})
})
describe('cloud asset mode', () => {
const createTestAsset = (
id: string,

View File

@@ -50,7 +50,7 @@ function getMediaUrl(
type: 'input' | 'output',
assetKind: AssetKind | undefined
): string {
if (!['image', 'video', 'audio', 'mesh'].includes(assetKind ?? '')) return ''
if (!['image', 'video', 'audio'].includes(assetKind ?? '')) return ''
const params = new URLSearchParams({ filename, type })
appendCloudResParam(params, filename)
return `/api/view?${params}`
@@ -183,7 +183,9 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
items.push({
id: `output-${asset.id}`,
preview_url:
asset.preview_url || getMediaUrl(asset.name, 'output', kind),
kind === 'mesh'
? ''
: asset.preview_url || getMediaUrl(asset.name, 'output', kind),
name: annotatedPath,
label: getDisplayLabel(annotatedPath, labelFn)
})

View File

@@ -8,6 +8,7 @@ import {
import type { RouteLocationNormalized } from 'vue-router'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isBackendReachable } from '@/platform/connectionPanel/backendReachable'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -29,13 +30,17 @@ const isFileProtocol = window.location.protocol === 'file:'
* Determine base path for the router.
* - Electron: always root
* - Cloud: use Vite's BASE_URL (configured at build time)
* - Standard web (including reverse proxy subpaths): use window.location.pathname
* to support deployments like http://mysite.com/ComfyUI/
* - Standard web: a deploy directory pathname ends with `/`
* (e.g. `/ComfyUI/`) — use it as base to support reverse-proxy subpaths.
* A SPA route pathname does not end with `/` (e.g. `/connect`) — fall back
* to BASE_URL so the route doesn't get appended to itself.
*/
function getBasePath(): string {
if (isDesktop) return '/'
if (isCloud) return import.meta.env?.BASE_URL || '/'
return window.location.pathname
const pathname = window.location.pathname
if (pathname.endsWith('/')) return pathname
return import.meta.env?.BASE_URL || '/'
}
const basePath = getBasePath()
@@ -66,6 +71,12 @@ const router = createRouter({
name: 'GraphView',
component: () => import('@/views/GraphView.vue'),
beforeEnter: async (_to, _from, next) => {
// Redirect to /connect when no ComfyUI backend is reachable
// (e.g. static deployments like Cloudflare Pages preview)
if (!(await isBackendReachable())) {
return next('/connect')
}
// Then check user store
const userStore = useUserStore()
await userStore.initialize()
@@ -82,6 +93,11 @@ const router = createRouter({
component: () => import('@/views/UserSelectView.vue')
}
]
},
{
path: '/connect',
name: 'ConnectionPanel',
component: () => import('@/views/ConnectionPanelView.vue')
}
],

View File

@@ -247,6 +247,7 @@ const zSystemStats = z.object({
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
comfy_api_base: z.string().optional(),
ram_total: z.number(),
ram_free: z.number(),
// Cloud-specific fields

View File

@@ -367,27 +367,52 @@ export class ComfyApi extends EventTarget {
*/
apiKey?: string
/**
* The origin (protocol + host) for the backend, when overridden via the
* preview connection panel. Empty string means use same-origin.
*/
private remoteOrigin = ''
constructor() {
super()
this.user = ''
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
const remoteBackend = localStorage.getItem('comfyui-preview-backend-url')
let parsedRemote: URL | null = null
if (remoteBackend) {
try {
parsedRemote = new URL(remoteBackend)
} catch {
// Corrupt value would crash the app at startup; drop it and fall back.
localStorage.removeItem('comfyui-preview-backend-url')
}
}
if (parsedRemote) {
this.remoteOrigin = parsedRemote.origin
this.api_host = parsedRemote.host
this.api_base = parsedRemote.pathname.replace(/\/+$/, '')
} else {
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
}
this.initialClientId = sessionStorage.getItem('clientId')
}
internalURL(route: string): string {
return this.api_base + '/internal' + route
return this.remoteOrigin + this.api_base + '/internal' + route
}
apiURL(route: string): string {
if (route.startsWith('/api')) return this.api_base + route
return this.api_base + '/api' + route
if (route.startsWith('/api'))
return this.remoteOrigin + this.api_base + route
return this.remoteOrigin + this.api_base + '/api' + route
}
fileURL(route: string): string {
return this.api_base + route
return this.remoteOrigin + this.api_base + route
}
/**
@@ -578,8 +603,14 @@ export class ComfyApi extends EventTarget {
}
}
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const baseUrl = `${protocol}://${this.api_host}${this.api_base}/ws`
// Derive WebSocket protocol from remote backend if set, else from page
let wsProtocol: string
if (this.remoteOrigin) {
wsProtocol = this.remoteOrigin.startsWith('https:') ? 'wss' : 'ws'
} else {
wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
}
const baseUrl = `${wsProtocol}://${this.api_host}${this.api_base}/ws`
const query = params.toString()
const wsUrl = query ? `${baseUrl}?${query}` : baseUrl

View File

@@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = {
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
],
properties: {},
properties: {
models: [
{
name: 'v1-5-pruned-emaonly-fp16.safetensors',
url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true',
directory: 'checkpoints'
}
]
},
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
}
],

View File

@@ -57,6 +57,12 @@ export interface TopbarBadge {
* Optional tooltip text to show on hover
*/
tooltip?: string
/**
* Optional links rendered as clickable anchors inside the popover.
* External URLs (starting with "http") open in a new tab; internal
* paths (e.g. "/connect") navigate within the SPA.
*/
popoverLinks?: Array<{ label: string; url: string }>
}
/*

View File

@@ -0,0 +1,286 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ConnectionPanelView from './ConnectionPanelView.vue'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({ changeTheme: vi.fn() })),
isNativeWindow: vi.fn(() => false)
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: false,
isCloud: false,
isNightly: false
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn()
})
}))
const mockLocalStorage = vi.hoisted(() => {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) delete store[key]
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
_store: store
}
})
vi.stubGlobal('localStorage', mockLocalStorage)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function renderPanel() {
return render(ConnectionPanelView, {
global: {
plugins: [i18n]
}
})
}
describe('ConnectionPanelView', () => {
beforeEach(() => {
mockLocalStorage.clear()
vi.restoreAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the backend URL input with default value', () => {
renderPanel()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
expect(input).toBeTruthy()
})
it('loads backend URL from localStorage', () => {
mockLocalStorage.setItem(
'comfyui-preview-backend-url',
'http://192.168.1.100:8188'
)
renderPanel()
const input = screen.getByDisplayValue(
'http://192.168.1.100:8188'
) as HTMLInputElement
expect(input).toBeTruthy()
})
it('shows test button', () => {
renderPanel()
expect(screen.getByRole('button', { name: /test/i })).toBeTruthy()
})
it('displays the comfy-cli install command', () => {
renderPanel()
expect(screen.getByText('pip install comfy-cli')).toBeTruthy()
})
it('displays the comfy launch command', () => {
renderPanel()
expect(
screen.getByText(
`comfy launch -- --enable-cors-header="${window.location.origin}"`
)
).toBeTruthy()
})
it('displays the local network access section', () => {
renderPanel()
expect(
screen.getByRole('heading', { level: 2, name: /local/i })
).toBeTruthy()
})
it('saves URL to localStorage on test', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
renderPanel()
const user = userEvent.setup()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
await user.clear(input)
await user.type(input, 'http://10.0.0.1:8188')
const testButton = screen.getByRole('button', { name: /test/i })
await user.click(testButton)
await vi.waitFor(() => {
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'comfyui-preview-backend-url',
'http://10.0.0.1:8188'
)
})
})
it('shows red HTTP indicator when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
// Stub WebSocket to never open so wsStatus also resolves to false
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'error') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
// i18n in tests is empty so the status text falls back to the key
expect(screen.getByText(/connectionPanel\.error/)).toBeTruthy()
})
})
it('normalizes a URL without protocol by prepending http://', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
renderPanel()
const user = userEvent.setup()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
await user.clear(input)
await user.type(input, '192.168.1.50:8188')
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'comfyui-preview-backend-url',
'http://192.168.1.50:8188'
)
})
})
it('links to staging platform when backend uses staging cloud base', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
system: {
argv: [
'main.py',
'--comfy-api-base=https://stagingapi.comfy.org'
]
}
})
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
const link = screen.getByRole('link', {
name: 'connectionPanel.getApiKeyLink'
})
expect(link.getAttribute('href')).toBe(
'https://stagingplatform.comfy.org/profile/api-keys'
)
})
})
it('parses backend cloud API base from system_stats argv', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
system: {
argv: [
'main.py',
'--enable-cors-header=*',
'--comfy-api-base',
'https://stagingapi.comfy.org'
]
}
})
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
expect(screen.getByText('https://stagingapi.comfy.org')).toBeTruthy()
})
})
it('reveals Connect & Open ComfyUI button after a successful HTTP test', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ system: { argv: [] } })
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
// i18n in tests is empty so the button label falls back to the key
expect(screen.getByText('connectionPanel.connectAndGo')).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,598 @@
<template>
<BaseViewTemplate dark>
<main
class="relative my-8 flex w-full max-w-lg flex-col gap-6 rounded-lg bg-(--comfy-menu-bg) p-8 shadow-lg"
>
<header class="flex flex-col gap-2">
<h1 class="text-xl font-semibold text-neutral-100">
{{ t('connectionPanel.title') }}
</h1>
<p class="text-sm text-neutral-400">
{{ t('connectionPanel.subtitle') }}
</p>
<aside
v-if="prNumber"
class="mt-1 flex flex-col gap-1 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200"
>
<p class="font-medium">
{{ t('connectionPanel.previewWarningTitle') }}
</p>
<p class="text-amber-200/85">
{{ t('connectionPanel.previewWarningBody') }}
</p>
<i18n-t
keypath="connectionPanel.previewProvenance"
tag="p"
class="text-amber-200/85"
>
<template #pr>
<a
:href="prUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
>#{{ prNumber }}</a
>
</template>
<template #commit>
<a
:href="commitUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
><code>{{ commitShort }}</code></a
>
</template>
<template #author>
<a
v-if="prAuthor"
:href="authorUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
>@{{ prAuthor }}</a
>
<span v-else>{{
t('connectionPanel.previewUnknownAuthor')
}}</span>
</template>
</i18n-t>
<p class="font-medium text-amber-100">
{{ t('connectionPanel.previewTrustWarning') }}
</p>
</aside>
</header>
<!-- Backend URL input -->
<section class="flex flex-col gap-2">
<label for="backend-url" class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.backendUrl') }}
</label>
<div class="flex gap-2">
<input
id="backend-url"
v-model="backendUrl"
type="url"
:placeholder="DEFAULT_BACKEND_URL"
class="flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-neutral-800 px-4 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
@keyup.enter="testConnection"
/>
<Button
variant="primary"
size="lg"
:loading="isTesting"
:disabled="isTesting"
@click="testConnection"
>
{{ t('connectionPanel.test') }}
</Button>
</div>
</section>
<!-- Connection status -->
<section
v-if="httpStatus !== null || wsStatus !== null"
role="status"
aria-live="polite"
class="flex flex-col gap-2 rounded-md bg-neutral-800/50 p-3"
>
<h2
class="text-xs font-medium tracking-wide text-neutral-400 uppercase"
>
{{ t('connectionPanel.status') }}
</h2>
<div class="flex gap-4 text-sm">
<span class="flex items-center gap-1.5">
<span
:class="
cn(
'inline-block size-2 rounded-full',
httpStatus === true && 'bg-green-500',
httpStatus === false && 'bg-red-500',
httpStatus === null && 'bg-neutral-600'
)
"
/>
{{ t('connectionPanel.http') }}
{{ httpStatus === true ? '✓' : httpStatus === false ? '✗' : '—' }}
</span>
<span class="flex items-center gap-1.5">
<span
:class="
cn(
'inline-block size-2 rounded-full',
wsStatus === true && 'bg-green-500',
wsStatus === false && 'bg-red-500',
wsStatus === null && 'bg-neutral-600'
)
"
/>
{{ t('connectionPanel.ws') }}
{{ wsStatus === true ? '✓' : wsStatus === false ? '✗' : '—' }}
</span>
</div>
<p v-if="connectionError" class="text-xs text-red-400">
{{ connectionError }}
</p>
<p
v-if="httpStatus === true && wsStatus === true"
class="text-xs text-green-400"
>
{{ t('connectionPanel.connected') }}
</p>
<!-- Backend cloud-API base + API key -->
<div
v-if="backendCloudBase"
class="flex flex-col gap-3 border-t border-neutral-700 pt-2"
>
<p class="text-xs text-neutral-400">
<span class="text-neutral-500"
>{{ t('connectionPanel.backendCloud') }}
</span>
<code
class="ml-1 rounded-sm bg-neutral-900 px-1 py-0.5 text-neutral-200"
>{{ backendCloudBase }}</code
>
</p>
<p v-if="cloudMismatch" class="text-xs text-amber-400">
{{
t('connectionPanel.cloudMismatch', {
frontend: frontendCloudBase
})
}}
</p>
<!-- API key input hidden when --disable-api-nodes -->
<div v-if="!isApiNodeDisabled" class="flex flex-col gap-1.5">
<label for="api-key" class="text-xs font-medium text-neutral-300">
{{ t('connectionPanel.apiKey') }}
<span class="ml-1 font-normal text-neutral-500">{{
t('connectionPanel.apiKeyOptional')
}}</span>
</label>
<div class="flex gap-2">
<input
id="api-key"
v-model="apiKeyInput"
type="password"
:placeholder="t('connectionPanel.apiKeyPlaceholder')"
autocomplete="current-password"
class="flex h-8 w-full min-w-0 appearance-none rounded-md border-none bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
@keyup.enter="testApiKey"
/>
<Button
variant="secondary"
size="sm"
:loading="isTestingApiKey"
:disabled="isTestingApiKey || !apiKeyInput.trim()"
@click="testApiKey"
>
{{ t('connectionPanel.test') }}
</Button>
</div>
<p v-if="apiKeyStatus === 'ok'" class="text-xs text-green-400">
{{ t('connectionPanel.apiKeyTestOk') }}
</p>
<p
v-else-if="apiKeyStatus === 'error'"
class="text-xs text-red-400"
>
{{ t('connectionPanel.apiKeyTestError') }}
</p>
<p v-else class="text-xs text-neutral-500">
{{ t('connectionPanel.apiKeyHint') }}
<a
v-if="apiKeyPageUrl"
:href="apiKeyPageUrl"
target="_blank"
rel="noopener"
class="ml-1 text-neutral-400 underline decoration-dotted hover:text-neutral-200"
>
{{ t('connectionPanel.getApiKeyLink') }}
</a>
</p>
</div>
<p v-else class="text-xs text-neutral-500">
{{ t('connectionPanel.apiKeyDisabledNotice') }}
</p>
</div>
<!-- Connect & Go button -->
<Button
v-if="httpStatus === true"
variant="primary"
size="lg"
class="mt-2 w-full"
@click="connectAndGo"
>
{{ t('connectionPanel.connectAndGo') }}
</Button>
</section>
<!-- Quick Start with Comfy CLI -->
<section class="flex flex-col gap-3">
<h2 class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.quickStart') }}
</h2>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.quickStartDescription') }}
</p>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step1InstallUv') }}
</span>
<CopyCodeBlock
text="curl -LsSf https://astral.sh/uv/install.sh | sh"
/>
<CopyCodeBlock
text='powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
/>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.uvNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step2InstallComfyui') }}
</span>
<CopyCodeBlock
text="uv pip install comfy-cli --system && comfy install"
/>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.managerIncludedNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step3Launch') }}
</span>
<CopyCodeBlock :text="launchCmd" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.corsOriginNote') }}
</p>
</div>
</div>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.corsNote') }}
</p>
<aside
class="flex flex-col gap-1 rounded-md border border-neutral-700 bg-neutral-800/50 p-3"
>
<h3 class="text-xs font-medium text-neutral-300">
{{ t('connectionPanel.managerTitle') }}
</h3>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.managerDescription') }}
</p>
<a
href="https://github.com/Comfy-Org/ComfyUI-Manager"
target="_blank"
rel="noopener"
class="text-xs text-neutral-300 underline hover:text-neutral-100"
>
{{ t('connectionPanel.managerLearnMore') }}
</a>
</aside>
</section>
<!-- Alternative: manual python / pip -->
<details class="group">
<summary
class="cursor-pointer text-sm font-medium text-neutral-400 hover:text-neutral-300"
>
{{ t('connectionPanel.altManualSetup') }}
</summary>
<div class="mt-2 flex flex-col gap-3">
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.altPipDescription') }}
</p>
<CopyCodeBlock text="pip install comfy-cli" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.altPipNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.altManagerDescription') }}
</p>
<CopyCodeBlock
text="git clone https://github.com/Comfy-Org/ComfyUI-Manager.git custom_nodes/ComfyUI-Manager"
/>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.guideDescription') }}
</p>
<CopyCodeBlock :text="pythonMainCmd" />
</div>
</div>
</details>
<!-- Local network access -->
<section class="flex flex-col gap-3">
<h2 class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.localAccess') }}
</h2>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.localAccessDescription') }}
</p>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.localAccessListenDescription') }}
</p>
<CopyCodeBlock :text="launchListenCmd" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.localAccessListenNote') }}
</p>
</div>
</section>
<footer
class="flex items-center justify-between border-t border-neutral-700 pt-4 text-xs text-neutral-500"
>
<span
:title="buildTooltip"
class="cursor-help underline decoration-dotted"
>
{{ buildLabel }}
</span>
<a
:href="repoUrl"
target="_blank"
rel="noopener"
class="text-neutral-400 hover:text-neutral-200"
>
{{ t('connectionPanel.source') }}
</a>
</footer>
</main>
</BaseViewTemplate>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import CopyCodeBlock from '@/components/connection/CopyCodeBlock.vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
getComfyApiBaseUrl,
getPlatformBaseUrlForApiBase
} from '@/config/comfyApi'
import { resolveBackendCloudBase } from '@/platform/connectionPanel/resolveBackendCloudBase'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
type SystemStats = {
system?: { argv?: string[]; comfy_api_base?: string }
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '')
}
const { t } = useI18n()
const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8188'
const STORAGE_KEY = 'comfyui-preview-backend-url'
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
const corsOrigin = window.location.origin
const backendUrl = ref(localStorage.getItem(STORAGE_KEY) || DEFAULT_BACKEND_URL)
const API_KEY_STORAGE_KEY = 'comfy_api_key'
const apiKeyInput = ref(localStorage.getItem(API_KEY_STORAGE_KEY) ?? '')
const launchCmd = `comfy launch -- --enable-cors-header="${corsOrigin}"`
const launchListenCmd = `comfy launch -- --listen --enable-cors-header="${corsOrigin}"`
const pythonMainCmd = `python main.py --enable-cors-header="${corsOrigin}"`
const isTesting = ref(false)
const httpStatus = ref<boolean | null>(null)
const wsStatus = ref<boolean | null>(null)
const connectionError = ref('')
const backendCloudBase = ref<string | null>(null)
const isApiNodeDisabled = ref(false)
const isTestingApiKey = ref(false)
const apiKeyStatus = ref<'idle' | 'ok' | 'error'>('idle')
const frontendCloudBase = stripTrailingSlash(getComfyApiBaseUrl())
const cloudMismatch = computed(
() =>
backendCloudBase.value !== null &&
backendCloudBase.value !== frontendCloudBase
)
const apiKeyPageUrl = computed(() => {
if (!backendCloudBase.value) return null
const platform = getPlatformBaseUrlForApiBase(backendCloudBase.value)
return platform ? `${platform}/profile/api-keys` : null
})
function normalizeUrl(raw: string): string {
let url = raw.trim()
if (!url) url = DEFAULT_BACKEND_URL
if (!/^https?:\/\//i.test(url)) url = 'http://' + url
return url.replace(/\/+$/, '')
}
async function fetchSystemStats(base: string): Promise<SystemStats | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch(`${base}/api/system_stats`, {
signal: controller.signal
})
if (!res.ok) return null
return (await res.json()) as SystemStats
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
function testWs(base: string): Promise<boolean> {
return new Promise((resolve) => {
const wsUrl = new URL(base)
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl.pathname = '/ws'
wsUrl.search = ''
wsUrl.hash = ''
const ws = new WebSocket(wsUrl.toString())
const timeout = setTimeout(() => {
ws.close()
resolve(false)
}, 5000)
ws.addEventListener('open', () => {
clearTimeout(timeout)
ws.close()
resolve(true)
})
ws.addEventListener('error', () => {
clearTimeout(timeout)
resolve(false)
})
})
}
async function testConnection() {
isTesting.value = true
httpStatus.value = null
wsStatus.value = null
connectionError.value = ''
backendCloudBase.value = null
const base = normalizeUrl(backendUrl.value)
backendUrl.value = base
localStorage.setItem(STORAGE_KEY, base)
try {
const [stats, ws] = await Promise.all([
fetchSystemStats(base),
testWs(base)
])
httpStatus.value = stats !== null
wsStatus.value = ws
backendCloudBase.value = stats
? resolveBackendCloudBase(stats.system)
: null
isApiNodeDisabled.value =
stats?.system?.argv?.includes('--disable-api-nodes') ?? false
if (stats === null && !ws) {
connectionError.value = t('connectionPanel.errorUnreachable')
} else if (stats === null) {
connectionError.value = t('connectionPanel.errorHttpFailed')
} else if (!ws) {
connectionError.value = t('connectionPanel.errorWsFailed')
}
} catch {
httpStatus.value = false
wsStatus.value = false
connectionError.value = t('connectionPanel.errorUnreachable')
} finally {
isTesting.value = false
}
}
async function testApiKey() {
const key = apiKeyInput.value.trim()
if (!key) return
isTestingApiKey.value = true
apiKeyStatus.value = 'idle'
const base = backendCloudBase.value ?? frontendCloudBase
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
try {
const res = await fetch(`${base}/customers`, {
method: 'POST',
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
signal: controller.signal
})
apiKeyStatus.value = res.ok ? 'ok' : 'error'
} catch {
apiKeyStatus.value = 'error'
} finally {
clearTimeout(timeout)
isTestingApiKey.value = false
}
}
function connectAndGo() {
const base = normalizeUrl(backendUrl.value)
localStorage.setItem(STORAGE_KEY, base)
const trimmedKey = apiKeyInput.value.trim()
if (trimmedKey) {
localStorage.setItem(API_KEY_STORAGE_KEY, trimmedKey)
}
// Full page reload so ComfyApi constructor picks up the new backend URL
window.location.href = import.meta.env.BASE_URL || '/'
}
const version = __COMFYUI_FRONTEND_VERSION__
const commit = __COMFYUI_FRONTEND_COMMIT__
const branch = __CI_BRANCH__
const prNumber = __CI_PR_NUMBER__
const prAuthor = __CI_PR_AUTHOR__
const runId = __CI_RUN_ID__
const jobId = __CI_JOB_ID__
const commitShort = commit ? commit.slice(0, 8) : ''
const prUrl = prNumber ? `${REPO}/pull/${prNumber}` : REPO
const commitUrl = commit ? `${REPO}/commit/${commit}` : REPO
const authorUrl = prAuthor ? `https://github.com/${prAuthor}` : ''
const buildLabel = computed(() => {
if (prNumber) return t('connectionPanel.buildPr', { prNumber })
if (branch) return branch
return t('connectionPanel.buildVersion', { version })
})
const buildTooltip = computed(() => {
const parts = [t('connectionPanel.tooltipVersion', { version })]
if (commit)
parts.push(
t('connectionPanel.tooltipCommit', { commit: commit.slice(0, 8) })
)
if (branch) parts.push(t('connectionPanel.tooltipBranch', { branch }))
if (runId) parts.push(t('connectionPanel.tooltipRunId', { runId }))
if (jobId) parts.push(t('connectionPanel.tooltipJobId', { jobId }))
return parts.join('\n')
})
const repoUrl = computed(() => {
if (prNumber) return `${REPO}/pull/${prNumber}`
if (branch) return `${REPO}/tree/${branch}`
return REPO
})
onMounted(() => {
document.getElementById('splash-loader')?.remove()
})
</script>

View File

@@ -13,7 +13,7 @@
ref="topMenuRef"
class="app-drag h-(--comfy-topbar-height) w-full"
/>
<div class="flex w-full grow items-center justify-center overflow-auto">
<div class="grid w-full grow place-items-center overflow-auto">
<slot />
</div>
</div>

7
src/vite-env.d.ts vendored
View File

@@ -17,6 +17,13 @@ declare global {
__COMFYUI_FRONTEND_VERSION__: string
}
const __COMFYUI_FRONTEND_COMMIT__: string
const __CI_BRANCH__: string
const __CI_PR_NUMBER__: string
const __CI_PR_AUTHOR__: string
const __CI_RUN_ID__: string
const __CI_JOB_ID__: string
interface ImportMetaEnv {
VITE_APP_VERSION?: string
}

View File

@@ -626,7 +626,12 @@ export default defineConfig({
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY),
__CI_BRANCH__: JSON.stringify(process.env.CI_BRANCH || ''),
__CI_PR_NUMBER__: JSON.stringify(process.env.CI_PR_NUMBER || ''),
__CI_PR_AUTHOR__: JSON.stringify(process.env.CI_PR_AUTHOR || ''),
__CI_RUN_ID__: JSON.stringify(process.env.CI_RUN_ID || ''),
__CI_JOB_ID__: JSON.stringify(process.env.CI_JOB_ID || '')
},
resolve: {